Repository: air-verse/air Branch: master Commit: cc5781144c59 Files: 65 Total size: 294.9 KB Directory structure: gitextract_7s9z9yf2/ ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── build.yml │ ├── release.yml │ ├── smoke_test.yml │ └── smoke_test_reuse_job.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── AGENTS.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README-ja.md ├── README-zh_cn.md ├── README-zh_tw.md ├── README.md ├── air_example.toml ├── go.mod ├── go.sum ├── hack/ │ └── check.sh ├── hooks/ │ └── pre-commit ├── install.sh ├── main.go ├── runner/ │ ├── _testdata/ │ │ ├── both/ │ │ │ └── .air.toml │ │ ├── child.sh │ │ ├── grandchild.sh │ │ ├── invalid_toml/ │ │ │ └── .air.toml │ │ ├── run-detached-process.sh │ │ ├── run-many-processes.sh │ │ ├── toml/ │ │ │ └── .air.toml │ │ └── watching/ │ │ └── inner/ │ │ └── test.txt │ ├── cmdarg_test.go │ ├── common.go │ ├── config.go │ ├── config_test.go │ ├── engine.go │ ├── engine_test.go │ ├── exiter.go │ ├── flag.go │ ├── flag_test.go │ ├── logger.go │ ├── proxy.go │ ├── proxy.js │ ├── proxy_stream.go │ ├── proxy_stream_test.go │ ├── proxy_test.go │ ├── test_util.go │ ├── util.go │ ├── util_linux.go │ ├── util_linux_test.go │ ├── util_test.go │ ├── util_unix.go │ ├── util_windows.go │ ├── util_windows_test.go │ ├── watcher.go │ └── worker.js ├── smoke_test/ │ ├── check_rebuild/ │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ └── smoke_test.py └── version.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ bin vendor tmp air ================================================ FILE: .editorconfig ================================================ root = true [Makefile] indent_style = tab [*.go] indent_style = tab indent_size = 4 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms patreon: cosmtrek github: xiantang open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: xiantang --- **Describe the bug** Provide a clear and concise description of the issue you encountered. **To Reproduce** - [ ] Provide a minimal reproducible example (ideally as a PR to https://github.com/air-verse/air-reproducible-example). - [ ] Include relevant parts of your `.air.toml` (redact secrets) **or** the exact `air` command you ran. - [ ] Describe the steps to reproduce the issue clearly in that example. **Expected behavior** Describe clearly and concisely what you expected to happen. **Screenshots** If applicable, include screenshots to help illustrate the issue. **Desktop (please complete the following information):** - OS: [e.g., macOS 13.3.1] - Air Version: [e.g., v1.63.2] **Additional context** - Please ensure your issue is written in English as the primary language. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: push: pull_request: branches: [master] jobs: build: strategy: matrix: os: [ubuntu-latest, macos-latest] name: Build runs-on: ${{ matrix.os }} steps: - name: Check out code uses: actions/checkout@v4 - name: Setup Go id: go uses: actions/setup-go@v5 with: go-version: ^1.25 - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: # Build golangci-lint from source with the configured Go version install-mode: goinstall version: latest - name: Install dependency run: if [ $(uname) == "Darwin" ]; then brew install gnu-sed ;fi - name: Build run: make build - name: Run unit tests env: CI: "true" run: go install github.com/go-delve/delve/cmd/dlv@latest && go test ./... -v -timeout=5m -covermode=count -coverprofile=coverage.txt - name: Upload Coverage report to CodeCov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.txt verbose: true unit_tests_windows: name: Unit Tests (Windows) runs-on: windows-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Normalize line endings (Windows) if: runner.os == 'Windows' run: | git config core.autocrlf false git config core.eol lf git checkout-index -f -a - name: Setup Go id: go uses: actions/setup-go@v5 with: go-version: ^1.25 - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: install-mode: goinstall version: latest - name: Run unit tests env: CI: "true" run: go install github.com/go-delve/delve/cmd/dlv@latest && go test ./... -v -timeout=5m -covermode=count -coverprofile=coverage.txt push_to_docker_latest: name: Push master code to docker latest image if: github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest steps: - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push id: docker_build uses: docker/build-push-action@v5 with: push: true platforms: linux/amd64,linux/arm64 tags: cosmtrek/air:latest - name: Show image digest run: echo ${{ steps.docker_build.outputs.digest }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: pull_request: branches: [master] jobs: release: name: Release runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') steps: - name: Check out code uses: actions/checkout@v4 - name: Normalize line endings (Windows) if: runner.os == 'Windows' run: | git config core.autocrlf false git config core.eol lf git checkout-index -f -a - name: Setup Go uses: actions/setup-go@v5 with: go-version: ^1.25 - name: Set GOVERSION run: echo "GOVERSION=$(go version | sed -r 's/go version go(.*)\ .*/\1/')" >> $GITHUB_ENV - name: Set AirVersion run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - name: Show version run: echo ${{ env.GOVERSION }} ${{ env.VERSION }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Push to DockerHub id: docker_build uses: docker/build-push-action@v5 with: push: true platforms: linux/amd64,linux/arm64 tags: cosmtrek/air:${{ env.VERSION }} - name: Show docker image digest run: echo ${{ steps.docker_build.outputs.digest }} ================================================ FILE: .github/workflows/smoke_test.yml ================================================ name: Smoke test on: push: pull_request: jobs: smoke_test_ubuntu: uses: ./.github/workflows/smoke_test_reuse_job.yml with: run_on: ubuntu-latest smoke_test_macos: uses: ./.github/workflows/smoke_test_reuse_job.yml with: run_on: macos-latest smoke_test_windows: uses: ./.github/workflows/smoke_test_reuse_job.yml with: run_on: windows-latest ================================================ FILE: .github/workflows/smoke_test_reuse_job.yml ================================================ name: Reusable smoke test on: workflow_call: inputs: run_on: required: true type: string jobs: smoke_test: name: Smoke test runs-on: ${{ inputs.run_on }} steps: - name: Check out code uses: actions/checkout@v5 - name: Normalize line endings (Windows) if: runner.os == 'Windows' run: | git config core.autocrlf false git config core.eol lf git checkout-index -f -a - name: Setup Go id: go uses: actions/setup-go@v6 with: go-version: ^1.25 - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: install-mode: goinstall version: latest - name: Install if: runner.os != 'Windows' run: make install - name: Install (Windows) if: runner.os == 'Windows' run: go install . - name: Check rebuild if: runner.os != 'Windows' id: check_rebuild_unix working-directory: ./smoke_test/check_rebuild run: | nohup air > nohup.out 2> nohup.err < /dev/null & sleep 15 echo "" >> main.go sleep 5 cat nohup.out count=$(grep "running" nohup.out | wc -l) if [ "$count" -eq "2" ]; then echo "value=PASS" >> "$GITHUB_OUTPUT" else echo "value=FAIL" >> "$GITHUB_OUTPUT" fi - name: Check rebuild (Windows) if: runner.os == 'Windows' id: check_rebuild_windows working-directory: ./smoke_test/check_rebuild shell: pwsh run: | $log = "nohup.out" $err = "nohup.err" $goPath = (go env GOPATH) $airPath = Join-Path $goPath "bin\air.exe" $process = Start-Process -FilePath $airPath -WorkingDirectory (Get-Location) -RedirectStandardOutput $log -RedirectStandardError $err -NoNewWindow -PassThru Start-Sleep -Seconds 15 Add-Content -Path "main.go" -Value "`n" Start-Sleep -Seconds 5 if (Test-Path $log) { Get-Content $log } if (Test-Path $log) { $count = (Select-String -Path $log -Pattern "running" | Measure-Object).Count } else { $count = 0 } if ($count -eq 2) { "value=PASS" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 } else { "value=FAIL" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 } if ($process -and -not $process.HasExited) { $process.Kill() } - uses: nick-invision/assert-action@v2 with: expected: "PASS" actual: ${{ steps.check_rebuild_unix.outputs.value || steps.check_rebuild_windows.outputs.value }} ================================================ FILE: .gitignore ================================================ *.o *.a *.so *.test *.prof *.out vendor/ tmp/ # IDE specific files .vscode .idea # build output air ================================================ FILE: .golangci.yml ================================================ version: "2" linters: default: none enable: - copyloopvar - errcheck - ineffassign - misspell - revive - staticcheck - testifylint - unconvert - unused exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ formatters: enable: - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .goreleaser.yml ================================================ builds: - goos: - linux - windows - darwin ignore: - goos: darwin goarch: 386 ldflags: - -s -w -X "main.airVersion={{.Version}}" - -s -w -X "main.goVersion={{.Env.GOVERSION}}" env: - CGO_ENABLED=0 archives: - id: tar.gz format: tar.gz - id: binary format: binary ================================================ FILE: AGENTS.md ================================================ # AGENTS Guidelines for contributors and AI coding agents working in this repository. ## Goals - Keep changes minimal, focused, and idiomatic to Go. - When adding changes, do NOT break existing behavior. - Make changes small and easy to review. - Code and comments must be written in English. - Prefer root-cause fixes over band-aids; do not refactor unrelated code. - Maintain user-facing behavior and CLI flags unless explicitly changing them. ## Project Snapshot - Language: Go (modules enabled; `go 1.25` in `go.mod`). - Module path: `github.com/air-verse/air`. - Entry: `main.go`. - Core package: `runner/` (watcher, engine, flags, config, proxy). - Docs: `README*.md`, `air_example.toml`, `docs/`. - Tooling: `Makefile`, `hack/check.sh`, `hooks/pre-commit`, `.golangci.yml`. - Default config file: `.air.toml` (generated by `air init`). ## Repository Layout - `main.go`: CLI entrypoint and flag parsing. - `runner/engine.go`: build/run loop and watcher orchestration. - `runner/config.go`: config schema, defaults, parsing, validation. - `runner/flag.go`: CLI flag wiring. - `runner/watcher.go`: fsnotify vs polling watcher selection. - `runner/proxy*.go`: live-reload proxy, SSE stream, embedded JS. - `runner/util*.go`: platform helpers, process control, misc utilities. - `runner/*_test.go`: unit tests and platform-specific tests. - `hack/check.sh`: goimports + golangci-lint driver. - `hooks/pre-commit`: runs `hack/check.sh` on staged files. ## Build / Lint / Test - Install tools + hook: `make init` (goimports + golangci-lint v2.6.1). - Format + lint staged Go files: `make check`. - Format + lint all Go files: `make check scope=all`. - Build binary: `make build` (runs `make check` first). - Install locally: `make install` (runs `make check` first). - CI prep (module tidy): `make ci`. - Full test suite: `go test ./...` or `make test` (race + timeout). - CI tests: `make test-ci` (CI=true, longer timeout). - Pre-commit hook: runs `./hack/check.sh` on staged files. ### Single Test Recipes - Single package: `go test ./runner -v`. - Single test: `go test ./runner -run '^TestName$' -count=1 -v`. - Subtest: `go test ./runner -run '^TestName$/Subcase$' -count=1`. - All packages, one test: `go test ./... -run '^TestName$' -count=1`. - Add `-race` or `-timeout=3m` when debugging flakiness. ## Code Style (Go) - Formatting: run `goimports` (it also applies `gofmt`). - Imports: let `goimports` group stdlib, third-party, local imports. - Naming: `CamelCase` for exported, `lowerCamel` for unexported. - Initialisms: use standard forms (ID, URL, HTTP, JSON). - Receivers: keep receiver names short and consistent (`cfg`, `e`, `p`). - Types: prefer concrete types over `any`; avoid needless interfaces. - Constants: use `const` for defaults; use `0o755` style perms. - Zero values: design structs so zero values are valid when possible. - Early returns: avoid deeply nested `else` blocks. - Paths: use `filepath` for OS-safe path handling. - Shelling out: prefer `exec.Command` with args; use `/bin/sh -c` only when needed. - Platform code: maintain `*_linux.go`, `*_windows.go`, build tags parity. - Comments: keep short, English, and explain "why" more than "what". - Use `time.Duration` for timeouts and backoffs. - Prefer value receivers unless mutation or large copies require pointers. - Avoid globals except for constants and small immutable vars. - Treat nil slices as empty; do not rely on non-nil defaults. - Keep TOML/JSON tags aligned with field names and usage strings. ## Error Handling & Logging - Wrap errors with context: `fmt.Errorf("read config: %w", err)`. - Use `errors.Is/As` for comparisons, `errors.Join` for aggregates. - Avoid panics in packages; `log.Fatal` only at CLI entrypoints. - Log via `mainLog`, `buildLog`, `runnerLog`, `watcherLog` in `runner`. - Keep log output concise and consistent with existing prefixes. ## Concurrency & State - Guard shared state with `sync.Mutex` or `sync/atomic`. - Avoid data races; keep goroutines bounded and cancellable. - Use channels for signaling (see `buildRunCh` in `runner/engine.go`). - Use `context` and timeouts for operations that may block on I/O. ## Tests - Location: alongside code as `*_test.go`. - Style: table-driven tests with `t.Run` where it improves clarity. - Parallelism: use `t.Parallel()` only for isolated tests. - Helpers: use `t.Helper`, `t.TempDir`, `t.Setenv`, `t.Cleanup`. - Assertions: use `testify/require` for fatal checks, `testify/assert` for soft checks. - Fixtures: prefer `_testdata` and temp dirs; avoid network dependencies. - OS coverage: use `*_windows_test.go`, `*_linux_test.go` for platform behavior. ## Config / CLI Discipline - Config fields: edit `runner/config.go`, update parsing/defaults/tests. - CLI flags: update `runner/flag.go`, sync help text, update README usage. - Keep `air_example.toml` aligned with any config changes. - Behavior changes must include README updates and tests. ## Documentation - Update `README.md` for user-visible changes (flags, config, examples). - Keep `docs/` content accurate when behavior changes. - Add concise migration notes for breaking or behavioral changes. - Mention new environment variables or defaults in docs. ## Tooling and Linters - `make check` runs `hack/check.sh` -> `goimports` + `golangci-lint`. - Enabled linters include: `errcheck`, `revive`, `staticcheck`, `testifylint`, `unused`. - Fix lint warnings rather than suppressing unless clearly justified. - Keep changes compatible with the current Go version in `go.mod`. ## Scope and Safety - Avoid touching `vendor/`, `third_party/`, or generated files. - Do not change CLI output or flags unless explicitly requested. - Avoid broad refactors; keep changes localized to the feature/bug. - Prefer root-cause fixes over temporary workarounds. - Do not add dependencies unless there is a clear, reviewed need. - Assume offline mode; no external network usage. ## Cursor / Copilot Rules - No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found. - If any are added later, follow them and copy key points here. ## For AI Coding Agents (Codex CLI) - For multi-step tasks, maintain an explicit plan and mark progress. - Keep edits in a single focused patch per logical change. - Briefly state what you are about to do before running commands. - Prefer `rg` for searches and keep file reads to small chunks. - Validate with `make check` and `go test ./...` when changes touch Go code. - Avoid touching unrelated files; do not reformat the repo wholesale. ## Release Notes (maintainers) - Follow the README "Release" section for tagging; CI performs builds. --- Questions or uncertain scope? Open an issue or ask for clarification before implementing. ================================================ FILE: Dockerfile ================================================ FROM golang:1.26 AS builder LABEL maintainer="Rick Yu " ENV GOPATH /go ENV GO111MODULE on COPY . /go/src/github.com/air-verse/air WORKDIR /go/src/github.com/air-verse/air RUN --mount=type=cache,target=/go/pkg/mod go mod download RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make ci && make install FROM golang:1.26 COPY --from=builder /go/bin/air /go/bin/air ENTRYPOINT ["/go/bin/air"] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. {one line to give the program's name and a brief idea of what it does.} Copyright (C) {year} {name of author} This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: {project} Copyright (C) {year} {fullname} This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: Makefile ================================================ AIRVER := $(shell git describe --tags) LDFLAGS += -X "main.BuildTimestamp=$(shell date -u "+%Y-%m-%d %H:%M:%S")" LDFLAGS += -X "main.airVersion=$(AIRVER)" LDFLAGS += -X "main.goVersion=$(shell go version | sed -r 's/go version go(.*)\ .*/\1/')" GO := GO111MODULE=on CGO_ENABLED=0 go GOLANGCI_LINT_VERSION = v2.6.1 GOLANGCI_LINT_CURRENT := $(shell golangci-lint --version 2>/dev/null | sed -n 's/.*version \([0-9.]*\).*/v\1/p') .PHONY: init init: install-golangci-lint go install golang.org/x/tools/cmd/goimports@latest @echo "Install pre-commit hook" @ln -s $(shell pwd)/hooks/pre-commit $(shell pwd)/.git/hooks/pre-commit || true @chmod +x ./hack/check.sh .PHONY: install-golangci-lint install-golangci-lint: @echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)" @GO111MODULE=on go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) .PHONY: setup setup: init git init .PHONY: check check: @./hack/check.sh ${scope} .PHONY: test test: @go test ./... -v -race -timeout=3m .PHONY: test-ci test-ci: @CI=true go test ./... -v -timeout=5m .PHONY: ci ci: init @$(GO) mod tidy .PHONY: build build: check $(GO) build -ldflags '$(LDFLAGS)' .PHONY: install install: check @echo "Installing air..." @$(GO) install -ldflags '$(LDFLAGS)' .PHONY: release release: check GOOS=darwin GOARCH=amd64 $(GO) build -ldflags '$(LDFLAGS)' -o bin/darwin/air GOOS=linux GOARCH=amd64 $(GO) build -ldflags '$(LDFLAGS)' -o bin/linux/air GOOS=windows GOARCH=amd64 $(GO) build -ldflags '$(LDFLAGS)' -o bin/windows/air.exe .PHONY: docker-image docker-image: docker build -t cosmtrek/air:$(AIRVER) -f ./Dockerfile . .PHONY: push-docker-image push-docker-image: docker push cosmtrek/air:$(AIRVER) ================================================ FILE: README-ja.md ================================================ # :cloud: Air - Go アプリケーションのためのライブリロード [![Go](https://github.com/air-verse/air/actions/workflows/release.yml/badge.svg)](https://github.com/air-verse/air/actions?query=workflow%3AGo+branch%3Amaster) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/dcb95264cc504cad9c2a3d8b0795a7f8)](https://www.codacy.com/gh/air-verse/air/dashboard?utm_source=github.com&utm_medium=referral&utm_content=air-verse/air&utm_campaign=Badge_Grade) [![Go Report Card](https://goreportcard.com/badge/github.com/air-verse/air)](https://goreportcard.com/report/github.com/air-verse/air) [![codecov](https://codecov.io/gh/air-verse/air/branch/master/graph/badge.svg)](https://codecov.io/gh/air-verse/air) ![air](docs/air.png) English | [简体中文](README-zh_cn.md) | [繁體中文](README-zh_tw.md) | [日本語](README-ja.md) ## 動機 Go でウェブサイトを開発し始め、 [gin](https://github.com/gin-gonic/gin) を使っていた時、gin にはライブリロード機能がないのが残念でした。 そこで探し回って [fresh](https://github.com/pilu/fresh) を試してみましたが、あまり柔軟ではないようでした。なので、もっと良いものを書くことにしました。そうして、 Air が誕生しました。 加えて、 [pilu](https:///github.com/pilu) に感謝します。fresh がなければ、 Air もありませんでした。:) Air は Go アプリケーション開発用のライブリロードコマンドラインユーティリティです。プロジェクトのルートディレクトリで `air` を実行し、放置し、コードに集中してください。 注:このツールは本番環境へのホットデプロイとは無関係です。 ## 特徴 - カラフルなログ出力 - ビルドやその他のコマンドをカスタマイズ - サブディレクトリを除外することをサポート - Air 起動後は新しいディレクトリを監視します - より良いビルドプロセス ### 引数から指定された設定を上書き air は引数による設定をサポートします: 利用可能なコマンドライン引数を以下のコマンドで確認できます: ``` air -h ``` または ``` air --help ``` もしビルドコマンドと起動コマンドを設定したい場合は、設定ファイルを使わずに以下のようにコマンドを使うことができます: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" ``` 入力値としてリストを取る引数には、アイテムを区切るためにコンマを使用します: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" --build.exclude_dir "templates,build" ``` 従来の `build.bin` フィールドは非推奨で、今後のリリースで削除される予定です。代わりに `build.entrypoint` を使ってください。 `build.entrypoint` は実行ファイルだけを指定する文字列、または実行ファイルとデフォルト引数を並べた文字列配列のどちらでも指定できます。 ## インストール ### `go install` を使う場合(推奨) go 1.25以上を使う場合: ```shell go install github.com/air-verse/air@latest ``` ### `go get -tool` を使う場合 go 1.25以上を使う場合: ```shell go get -tool github.com/air-verse/air@latest # 使い方は以下の通りです: go tool air -v ``` ### `install.sh` を使う場合 ```shell # バイナリは $(go env GOPATH)/bin/air にインストールされます curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin # または./bin/にインストールすることもできます curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s air -v ``` ### [goblin.run](https://goblin.run) を使う場合 ```shell # バイナリは /usr/local/bin/air にインストールされます curl -sSfL https://goblin.run/github.com/air-verse/air | sh # 任意のパスに配置することもできます curl -sSfL https://goblin.run/github.com/air-verse/air | PREFIX=/tmp sh ``` ### ソフトウェアパッケージマネージャー [mise](https://github.com/jdx/mise) を使う場合 ```shell mise use -g air ``` ### Docker/Podman [cosmtrek/air](https://hub.docker.com/r/cosmtrek/air) という Docker イメージをプルしてください。 ```shell docker/podman run -it --rm \ -w "" \ -e "air_wd=" \ -v $(pwd): \ -p : \ cosmtrek/air -c ``` #### Docker/Podman .${SHELL}rc 通常のアプリケーションのように継続的に air を使いたい場合は、 ${SHELL}rc (Bash, Zsh, etc…)に関数を作成してください。 ```shell air() { podman/docker run -it --rm \ -w "$PWD" -v "$PWD":"$PWD" \ -p "$AIR_PORT":"$AIR_PORT" \ docker.io/cosmtrek/air "$@" } ``` ``はコンテナ内のプロジェクトのパスです。 例:`/go/example` コンテナに接続したい場合は、 `--entrypoint=bash` を追加してください。
Docker で動作するとあるプロジェクト: ```shell docker run -it --rm \ -w "/go/src/github.com/cosmtrek/hub" \ -v $(pwd):/go/src/github.com/cosmtrek/hub \ -p 9090:9090 \ cosmtrek/air ``` 別の例: ```shell cd /go/src/github.com/cosmtrek/hub AIR_PORT=8080 air -c "config.toml" ``` これは `$PWD` を現在のディレクトリに置き換え、 `$AIR_PORT` は公開するポートを指定し、 `$@` は-cのようなアプリケーション自体の引数を受け取るためのものです。
## 使い方 `.bashrc` または `.zshrc` に `alias air='~/.air'` を追加すると、入力の手間が省けます。 まずプロジェクトを移動します。 ```shell cd /path/to/your_project ``` 最もシンプルな使い方は以下の通りです。 ```shell # カレントディレクトリの `.air.toml` を優先し、見つからない場合はデフォルト値を使います air ``` 特定の設定ファイルを明示的に使う場合は `-c` を指定します。 ```shell air -c .air.toml ``` 次のコマンドを実行することで、カレントディレクトリに `.air.toml` 設定ファイルを初期化できます。 ```shell air init ``` その次に、追加の引数なしで `air` コマンドを実行すると、 `.air.toml` ファイルが設定として使用されます。 ```shell air ``` [air_example.toml](air_example.toml)を参考にして設定を編集します。 ### 実行時引数 air コマンドの後に引数を追加することで、ビルドしたバイナリを実行するための引数を渡すことができる。 ```shell # ./tmp/main benchを実行します air bench # ./tmp/main server --port 8080を実行します air server --port 8080 ``` air コマンドに渡す引数とビルドするバイナリを `--` 引数で区切ることができる。 ```shell # ./tmp/main -hを実行します air -- -h # カスタム設定で air を実行し、ビルドされたバイナリに -h 引数を渡す air -c .air.toml -- -h ``` ### Docker Compose ```yaml services: my-project-with-air: image: cosmtrek/air # working_dir の値はマップされたボリュームの値と同じでなければなりません working_dir: /project-package ports: - : environment: - ENV_A=${ENV_A} - ENV_B=${ENV_B} - ENV_C=${ENV_C} volumes: - ./project-relative-path/:/project-package/ ``` ### デバッグ `air -d`は全てのログを出力します。 ## air イメージを使いたくない Docker ユーザーのためのインストールと使い方 `Dockerfile` ```Dockerfile # 1.25以上の任意のバージョンを選択してください FROM golang:1.25-alpine WORKDIR /app RUN go install github.com/air-verse/air@latest COPY go.mod go.sum ./ RUN go mod download CMD ["air", "-c", ".air.toml"] ``` `docker-compose.yaml` ```yaml version: "3.8" services: web: build: context: . # Dockerfile へのパスを正してください dockerfile: Dockerfile ports: - 8080:3000 # ライブリロードのために、コードベースディレクトリを /app ディレクトリにバインド/マウントすることが重要です volumes: - ./:/app ``` ## Q&A ### "command not found: air" または "No such file or directory" ```shell export GOPATH=$HOME/xxxxx export PATH=$PATH:$GOROOT/bin:$GOPATH/bin export PATH=$PATH:$(go env GOPATH)/bin #この設定を .profile で確認し、追加した場合は .profile を source するのを忘れないでください!!! ``` ### bin に ' が含まれる場合の wsl でのエラー bin の\`'をエスケープするには`\`を使用したほうが良いです。関連する issue: [#305](https://github.com/air-verse/air/issues/305) ### 質問: ホットコンパイルのみを行い、何も実行しない方法は? [#365](https://github.com/air-verse/air/issues/365) ```toml [build] cmd = "/usr/bin/true" ``` ### 静的ファイルの変更時にブラウザを自動的にリロードする方法 詳細のために [#512](https://github.com/air-verse/air/issues/512) の issue を参照してください。 - 静的ファイルを `include_dir` 、`include_ext` 、`include_file` に配置していることを確かめてください。 - HTML に `` タグがあることを確かめてください。 - プロキシを有効にするには、以下の設定を行います: ```toml [proxy] enabled = true proxy_port = app_port = ``` ## 開発 必要な Go のバージョンは 1.25+ です(`go.mod` を参照)。 ```shell # プロジェクトをフォークしてください # クローンしてください mkdir -p $GOPATH/src/github.com/cosmtrek cd $GOPATH/src/github.com/cosmtrek git clone git@github.com:/air.git # 依存関係をインストールしてください cd air make ci # コードを探検してコーディングを楽しんでください! make install ``` Pull Request を受け付けています。 ### リリース ```shell # master にチェックアウトします git checkout master # リリースに必要なバージョンタグを付与します git tag v1.xx.x # リモートにプッシュします git push origin v1.xx.x # CI が実行され、新しいバージョンがリリースされます。約5分待つと最新バージョンを取得できます ``` ## スターヒストリー [![Star History Chart](https://api.star-history.com/svg?repos=air-verse/air&type=Date)](https://star-history.com/#air-verse/air&Date) ## スポンサー [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/cosmtrek) 多くのサポーターの方々に心から感謝します。私はいつも皆さんの優しさを忘れません。 ## ライセンス [GNU General Public License v3.0](LICENSE) ================================================ FILE: README-zh_cn.md ================================================ # Air [![Go](https://github.com/air-verse/air/actions/workflows/release.yml/badge.svg)](https://github.com/air-verse/air/actions?query=workflow%3AGo+branch%3Amaster) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/dcb95264cc504cad9c2a3d8b0795a7f8)](https://www.codacy.com/gh/air-verse/air/dashboard?utm_source=github.com&utm_medium=referral&utm_content=air-verse/air&utm_campaign=Badge_Grade) [![Go Report Card](https://goreportcard.com/badge/github.com/air-verse/air)](https://goreportcard.com/report/github.com/air-verse/air) [![codecov](https://codecov.io/gh/air-verse/air/branch/master/graph/badge.svg)](https://codecov.io/gh/air-verse/air) :cloud: 热重载 Go 应用的工具 ![air](docs/air.png) [English](README.md) | 简体中文 | [繁體中文](README-zh_tw.md) ## 开发动机 当我用 Go 和 [gin](https://github.com/gin-gonic/gin) 框架开发网站时,gin 缺乏实时重载的功能是令人遗憾的。我曾经尝试过 [fresh](https://github.com/pilu/fresh) ,但是它用起来不太灵活,所以我试着用更好的方式来重写它。Air 就这样诞生了。此外,非常感谢 [pilu](https://github.com/pilu)。没有 fresh 就不会有 air :) Air 是为 Go 应用开发设计的另外一个热重载的命令行工具。只需在你的项目根目录下输入 `air`,然后把它放在一边,专注于你的代码即可。 **注意**:该工具与生产环境的热部署无关。 ## 特色 - 彩色的日志输出 - 自定义构建或必要的命令 - 支持外部子目录 - 在 Air 启动之后,允许监听新创建的路径 - 更棒的构建过程 ### 使用参数覆盖指定配置 支持使用参数来配置 air 字段: 如果你只是想配置构建命令和运行命令,您可以直接使用以下命令,而无需配置文件: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" ``` 对于以列表形式输入的参数,使用逗号来分隔项目: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" --build.exclude_dir "templates,build" ``` 旧的 `build.bin` 字段已弃用,将在未来移除,请改用 `build.entrypoint`。 `build.entrypoint` 可以写成单个字符串(只指定可执行文件),也可以写成字符串数组(可执行文件和默认参数)。 ## 安装 ### 使用 `go install` (推荐) 使用 go 1.25 或更高版本: ```shell go install github.com/air-verse/air@latest ``` ### 使用 `go get -tool` 使用 go 1.25 或更高版本: ```shell go get -tool github.com/air-verse/air@latest # 然后像这样使用: go tool air -v ``` ### 使用 install.sh ```shell # binary 文件会是在 $(go env GOPATH)/bin/air curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin # 或者把它安装在 ./bin/ 路径下 curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s air -v ``` ### 使用 [goblin.run](https://goblin.run) ```shell # binary 将会安装到 /usr/local/bin/air curl -sSfL https://goblin.run/github.com/cosmtrek/air | sh # 自定义路径安装 curl -sSfL https://goblin.run/github.com/cosmtrek/air | PREFIX=/tmp sh ``` ### 使用软件包管理器 [mise](https://github.com/jdx/mise) ```shell mise use -g air ``` ### Docker/Podman 请拉取这个 Docker 镜像 [cosmtrek/air](https://hub.docker.com/r/cosmtrek/air). ```shell docker run -it --rm \ -w "" \ -e "air_wd=" \ -v $(pwd): \ -p : \ cosmtrek/air -c ``` #### Docker/Podman .${SHELL}rc 如果你想像正常应用程序一样连续使用 air,你可以在你的 ${SHELL}rc(Bash, Zsh 等)中创建一个函数 ```shell air() { podman/docker run -it --rm \ -w "$PWD" -v "$PWD":"$PWD" \ -p "$AIR_PORT":"$AIR_PORT" \ docker.io/cosmtrek/air "$@" } ``` `` 是容器中的项目路径,例如:`/go/example` 如果你想进入容器,请添加 `--entrypoint=bash`。
例如 我的一个项目运行在 Docker 中: ```shell docker run -it --rm \ -w "/go/src/github.com/cosmtrek/hub" \ -v $(pwd):/go/src/github.com/cosmtrek/hub \ -p 9090:9090 \ cosmtrek/air ``` 另一个例子: ```shell cd /go/src/github.com/cosmtrek/hub AIR_PORT=8080 air -c "config.toml" ``` 这将用当前目录替换 `$PWD`,`$AIR_PORT` 是要发布的端口,而 `$@` 用于接受应用程序本身的参数,例如 `-c`
## 使用方法 为了方便输入,您可以添加 `alias air='~/.air'` 到您的 `.bashrc` 或 `.zshrc` 文件中. 首先,进入你的项目文件夹 ```shell cd /path/to/your_project ``` 最简单的方法是执行 ```shell # 先尝试读取当前目录中的 `.air.toml`;如果不存在,则使用默认配置 air ``` 如果要显式指定配置文件,可以使用: ```shell air -c .air.toml ``` 您可以运行以下命令,将具有默认设置的 `.air.toml` 配置文件初始化到当前目录。 ```shell air init ``` 在这之后,你只需执行 `air` 命令,无需额外参数,它就能使用 `.air.toml` 文件中的配置了。 ```shell air ``` 如欲修改配置信息,请参考 [air_example.toml](air_example.toml) 文件. ### 运行时参数 您可以通过把变量添加在 air 命令之后来传递参数。 ```shell # 会执行 ./tmp/main bench air bench # 会执行 ./tmp/main server --port 8080 air server --port 8080 ``` 你可以使用 `--` 参数分隔传递给 air 命令和已构建二进制文件的参数。 ```shell # 会运行 ./tmp/main -h air -- -h # 会使用个性化配置来运行 air,然后把 -h 后的变量和值添加到运行的参数中 air -c .air.toml -- -h ``` ### Docker Compose ```yaml services: my-project-with-air: image: cosmtrek/air # working_dir value has to be the same of mapped volume working_dir: /project-package ports: - : environment: - ENV_A=${ENV_A} - ENV_B=${ENV_B} - ENV_C=${ENV_C} volumes: - ./project-relative-path/:/project-package/ ``` ### 调试 `air -d` 命令能打印所有日志。 ## Docker 用户安装和使用指南(如果不想使用 air 镜像) `Dockerfile` ```Dockerfile # 选择你想要的版本,>= 1.25 FROM golang:1.25-alpine WORKDIR /app RUN go install github.com/air-verse/air@latest COPY go.mod go.sum ./ RUN go mod download CMD ["air", "-c", ".air.toml"] ``` `docker-compose.yaml` ```yaml version: "3.8" services: web: build: context: . # 修改为你的 Dockerfile 路径 dockerfile: Dockerfile ports: - 8080:3000 # 为了实时重载,将代码目录绑定到 /app 目录是很重要的 volumes: - ./:/app ``` ## Q&A ### 遇到 "command not found: air" 或 "No such file or directory" 该怎么办? ```shell export GOPATH=$HOME/xxxxx export PATH=$PATH:$GOROOT/bin:$GOPATH/bin export PATH=$PATH:$(go env GOPATH)/bin <---- 请确认这行在您的配置信息中!!! ``` ### 在 wsl 下 bin 中包含 ' 时的错误 应该使用 `\` 来转义 bin 中的 `'`。相关问题:[#305](https://github.com/cosmtrek/air/issues/305) ### 问题:如何只进行热编译而不运行? [#365](https://github.com/cosmtrek/air/issues/365) ```toml [build] cmd = "/usr/bin/true" ``` ### 如何在静态文件更改时自动重新加载浏览器? 请参考 [#512](https://github.com/cosmtrek/air/issues/512). - 确保你的静态文件在 `include_dir`、`include_ext` 或 `include_file` 中。 - 确保你的 HTML 有一个 `` 标签。 - 通过配置以下内容开启代理: ```toml [proxy] enabled = true proxy_port = app_port = ``` ## 开发 请注意:当前需要 Go 1.25+(见 `go.mod`)。 ```shell # 1. 首先复刻(fork)这个项目 # 2. 其次克隆(clone)它 mkdir -p $GOPATH/src/github.com/cosmtrek cd $GOPATH/src/github.com/cosmtrek git clone git@github.com:/air.git # 3. 安装依赖 cd air make ci # 4. 这样就可以快乐地探索和玩耍啦! make install ``` 顺便说一句: 欢迎 PR~ ### 发布新版本 ```shell # 1. checkout 到 master 分支 git checkout master # 2. 添加需要发布的版本号 git tag v1.xx.x # 3. 推送到远程 git push origin v1.xx.x CI 将处理并发布新版本。等待大约 5 分钟,你就可以获取最新版本了。 ``` ## Star 历史 [![Star History Chart](https://api.star-history.com/svg?repos=cosmtrek/air&type=Date)](https://star-history.com/#cosmtrek/air&Date) ## 赞助 [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/cosmtrek) 非常感谢众多支持者。我一直铭记你们的善意。 ## 许可证 [GNU General Public License v3.0](LICENSE) ================================================ FILE: README-zh_tw.md ================================================ # :cloud: Air - Live reload for Go apps [![Go](https://github.com/air-verse/air/actions/workflows/release.yml/badge.svg)](https://github.com/air-verse/air/actions?query=workflow%3AGo+branch%3Amaster) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/dcb95264cc504cad9c2a3d8b0795a7f8)](https://www.codacy.com/gh/air-verse/air/dashboard?utm_source=github.com&utm_medium=referral&utm_content=air-verse/air&utm_campaign=Badge_Grade) [![Go Report Card](https://goreportcard.com/badge/github.com/air-verse/air)](https://goreportcard.com/report/github.com/air-verse/air) [![codecov](https://codecov.io/gh/air-verse/air/branch/master/graph/badge.svg)](https://codecov.io/gh/air-verse/air) ![air](docs/air.png) [English](README.md) | [简体中文](README-zh_cn.md) | 繁體中文 ## 開發動機 當我開始用 Go 開發網站並使用[gin](https://github.com/gin-gonic/gin)框架時,感到可惜的是 gin 缺乏自動重新編譯執行的方式。因此,我四處搜尋並嘗試使用[fresh](https://github.com/pilu/fresh),但它似乎不夠彈性,所以我打算重新寫得更好。最後,Air 就這麼誕生了。另外,非常感謝[pilu](https://github.com/pilu),如果沒有 fresh,就不會有 air :) Air 是一個另類的自動重新編譯執行命令列工具,用於開發 Go 應用。在你的項目根目錄下運行 `air`,將它執行於背景中,並專注於你的程式碼。 注意:此工具與生產環境的熱部署無關。 ## 功能列表 - 彩色的日誌輸出 - 自訂建立或任何命令 - 支援排除子目錄 - 允許在 Air 開始後監視新目錄 - 更佳的建置過程 ### 用參數覆寫指定的配置 支援將 air 做為參數的配置字段: 如果你想設定建置命令和執行命令,你可以在不需要配置檔案的情況下如下使用命令: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" ``` 對於需要輸入列表的參數,可以使用逗號將項目分隔: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" --build.exclude_dir "templates,build" ``` 舊的 `build.bin` 欄位已被棄用,未來版本會移除,請改用 `build.entrypoint`。 `build.entrypoint` 可以寫成單一字串(只指定執行檔),也可以寫成字串陣列(執行檔加上預設參數)。 ## 安裝 ### 使用 `go install` (推薦) 需要使用 go 1.25 或更高版本: ```shell go install github.com/air-verse/air@latest ``` ### 使用 `go get -tool` 需要使用 go 1.25 或更高版本: ```shell go get -tool github.com/air-verse/air@latest # 然後這樣使用: go tool air -v ``` ### 透過 install.sh ```shell # binary will be $(go env GOPATH)/bin/air curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin # or install it into ./bin/ curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s air -v ``` ### 透過 [goblin.run](https://goblin.run) ```shell # binary will be /usr/local/bin/air curl -sSfL https://goblin.run/github.com/air-verse/air | sh # to put to a custom path curl -sSfL https://goblin.run/github.com/air-verse/air | PREFIX=/tmp sh ``` ### 使用軟體套件管理器 [mise](https://github.com/jdx/mise) ```shell mise use -g air ``` ### 透過 `go install` 使用 go 1.25 或更高版本: ```bash go install github.com/air-verse/air@latest ``` ### Docker/Podman 請讀取 Docker 映像檔 [cosmtrek/air](https://hub.docker.com/r/cosmtrek/air). ```shell docker/podman run -it --rm \ -w "" \ -e "air_wd=" \ -v $(pwd): \ -p : \ cosmtrek/air -c ``` #### Docker/Podman .${SHELL}rc 如果你想像常規應用程式一樣持續使用 air,你可以在你的 ${SHELL}rc (Bash, Zsh, etc…) 中創建一個函數。 ```shell air() { podman/docker run -it --rm \ -w "$PWD" -v "$PWD":"$PWD" \ -p "$AIR_PORT":"$AIR_PORT" \ docker.io/cosmtrek/air "$@" } ``` `` 是你的容器中的專案路徑,例如:/go/example 如果你想要進入容器,請加上 --entrypoint=bash。
For example 我其中一個專案是在 Docker 中運行 ```shell docker run -it --rm \ -w "/go/src/github.com/cosmtrek/hub" \ -v $(pwd):/go/src/github.com/cosmtrek/hub \ -p 9090:9090 \ cosmtrek/air ``` 另一個例子 ```shell cd /go/src/github.com/cosmtrek/hub AIR_PORT=8080 air -c "config.toml" ``` 這將會用當前目錄替換 `$PWD`,`$AIR_PORT` 是發佈的端口,`$@` 是用來接受應用程式本身的參數,例如 -c
## 使用方式 為了減少輸入,你可以將 `alias air='~/.air'` 加到你的 `.bashrc` 或者 `.zshrc`。 首先,進入你的專案目錄 ```shell cd /path/to/your_project ``` 最簡單的使用方式是運行 ```shell # 先嘗試讀取目前目錄中的 `.air.toml`;如果不存在,則使用預設配置 air ``` 如果要明確指定配置檔,可以使用: ```shell air -c .air.toml ``` 你可以用以下命令初始化 `.air.toml` 配置檔到當前目錄,並使用預設設置。 ```shell air init ``` 此後,你可以只運行 `air` 命令,而不需要額外的參數,它將使用 `.air.toml` 檔案作為配置。 ```shell air ``` 要修改配置,請參閱 [air_example.toml](air_example.toml) 檔案。 ### 運行時參數 你可以在 air 命令後添加參數來運行已構建的二進制檔。 ```shell # Will run ./tmp/main bench air bench # Will run ./tmp/main server --port 8080 air server --port 8080 ``` 你可以使用 `--` 參數來分隔傳遞給 air 命令和已建構的二進制檔的參數。 ```shell # Will run ./tmp/main -h air -- -h # Will run air with custom config and pass -h argument to the built binary air -c .air.toml -- -h ``` ### Docker Compose ```yaml services: my-project-with-air: image: cosmtrek/air # working_dir value has to be the same of mapped volume working_dir: /project-package ports: - : environment: - ENV_A=${ENV_A} - ENV_B=${ENV_B} - ENV_C=${ENV_C} volumes: - ./project-relative-path/:/project-package/ ``` ### 除錯 `air -d` prints all logs. ## 對於不想使用 air 映像的 Docker 使用者的安裝與使用方法 `Dockerfile` ```Dockerfile # Choose whatever you want, version >= 1.25 FROM golang:1.25-alpine WORKDIR /app RUN go install github.com/air-verse/air@latest COPY go.mod go.sum ./ RUN go mod download CMD ["air", "-c", ".air.toml"] ``` `docker-compose.yaml` ```yaml version: "3.8" services: web: build: context: . # Correct the path to your Dockerfile dockerfile: Dockerfile ports: - 8080:3000 # Important to bind/mount your codebase dir to /app dir for live reload volumes: - ./:/app ``` ## Q&A ### "找不到命令:air" 或者 "找不到檔案或目錄" ```shell export GOPATH=$HOME/xxxxx export PATH=$PATH:$GOROOT/bin:$GOPATH/bin export PATH=$PATH:$(go env GOPATH)/bin <---- Confirm this line in you profile!!! ``` ### 當 bin 中包含 ' 時,在 wsl 下的錯誤 應該使用 `\` 來轉義 bin 中的 `'。相關議題:[#305](https://github.com/air-verse/air/issues/305) ## 開發 請注意:目前需要 Go 1.25+(請參考 `go.mod`)。 ```shell # Fork this project # Clone it mkdir -p $GOPATH/src/github.com/cosmtrek cd $GOPATH/src/github.com/cosmtrek git clone git@github.com:/air.git # Install dependencies cd air make ci # Explore it and happy hacking! make install ``` 歡迎提出 Pull Request ### 發佈版本 ```shell # Checkout to master git checkout master # Add the version that needs to be released git tag v1.xx.x # Push to remote git push origin v1.xx.x # The CI will process and release a new version. Wait about 5 min, and you can fetch the latest version ``` ## 星星歷史 [![Star History Chart](https://api.star-history.com/svg?repos=air-verse/air&type=Date)](https://star-history.com/#air-verse/air&Date) ## 贊助專案 [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/cosmtrek) 非常感謝大量的支持者。我一直記得你們的善意。 ## 授權 [GNU General Public License v3.0](LICENSE) ================================================ FILE: README.md ================================================ # :cloud: Air - Live reload for Go apps [![Go](https://github.com/air-verse/air/actions/workflows/release.yml/badge.svg)](https://github.com/air-verse/air/actions?query=workflow%3AGo+branch%3Amaster) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/dcb95264cc504cad9c2a3d8b0795a7f8)](https://www.codacy.com/gh/air-verse/air/dashboard?utm_source=github.com&utm_medium=referral&utm_content=air-verse/air&utm_campaign=Badge_Grade) [![Go Report Card](https://goreportcard.com/badge/github.com/air-verse/air)](https://goreportcard.com/report/github.com/air-verse/air) [![codecov](https://codecov.io/gh/air-verse/air/branch/master/graph/badge.svg)](https://codecov.io/gh/air-verse/air) ![air](docs/air.png) English | [简体中文](README-zh_cn.md) | [繁體中文](README-zh_tw.md) | [日本語](README-ja.md) ## Motivation When I started developing websites in Go and using [gin](https://github.com/gin-gonic/gin) framework, it was a pity that gin lacked a live-reloading function. So I searched around and tried [fresh](https://github.com/pilu/fresh), it seems not much flexible, so I intended to rewrite it better. Finally, Air's born. In addition, great thanks to [pilu](https://github.com/pilu), no fresh, no air :) Air is yet another live-reloading command line utility for developing Go applications. Run `air` in your project root directory, leave it alone, and focus on your code. Note: This tool has nothing to do with hot-deploy for production. ## Features - Colorful log output - Customize build or any command - Support excluding subdirectories - Allow watching new directories after Air started - Better building process - Configurable `.env` file loading ### Overwrite specify configuration from arguments Support air config fields as arguments: You can view the available command-line arguments by running the following commands: ``` air -h ``` or ``` air --help ``` If you want to config build command and run command, you can use like the following command without the config file: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" ``` Use a comma to separate items for arguments that take a list as input: ```shell air --build.cmd "go build -o bin/api cmd/run.go" --build.entrypoint "./bin/api" --build.exclude_dir "templates,build" ``` ## Installation ### Via `go install` (Recommended) With go 1.25 or higher: ```shell go install github.com/air-verse/air@latest ``` ### Via `go get -tool` (project install) With go 1.25 or higher: ```shell go get -tool github.com/air-verse/air@latest # then use it like so: go tool air -v ``` ### Via install.sh ```shell # binary will be $(go env GOPATH)/bin/air curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin # or install it into ./bin/ curl -sSfL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s air -v ``` ### Via [goblin.run](https://goblin.run) ```shell # binary will be /usr/local/bin/air curl -sSfL https://goblin.run/github.com/air-verse/air | sh # to put to a custom path curl -sSfL https://goblin.run/github.com/air-verse/air | PREFIX=/tmp sh ``` ### Via [Homebrew](https://github.com/Homebrew/brew) ```shell brew install go-air ``` ### Using software package manager [mise](https://github.com/jdx/mise) ```shell mise use -g air ``` ### Docker/Podman Please pull this Docker image [cosmtrek/air](https://hub.docker.com/r/cosmtrek/air). ```shell docker/podman run -it --rm \ -w "" \ -e "air_wd=" \ -v $(pwd): \ -p : \ cosmtrek/air -c ``` #### Docker/Podman .${SHELL}rc if you want to use air continuously like a normal app, you can create a function in your ${SHELL}rc (Bash, Zsh, etc…) ```shell air() { podman/docker run -it --rm \ -w "$PWD" -v "$PWD":"$PWD" \ -p "$AIR_PORT":"$AIR_PORT" \ docker.io/cosmtrek/air "$@" } ``` `` is your project path in container, eg: /go/example if you want to enter the container, Please add --entrypoint=bash.
For example One of my project runs in Docker: ```shell docker run -it --rm \ -w "/go/src/github.com/cosmtrek/hub" \ -v $(pwd):/go/src/github.com/cosmtrek/hub \ -p 9090:9090 \ cosmtrek/air ``` Another example: ```shell cd /go/src/github.com/cosmtrek/hub AIR_PORT=8080 air -c "config.toml" ``` this will replace `$PWD` with the current directory, `$AIR_PORT` is the port where to publish and `$@` is to accept arguments of the application itself for example -c
## Usage For less typing, you could add `alias air='~/.air'` to your `.bashrc` or `.zshrc`. First enter into your project ```shell cd /path/to/your_project ``` The simplest usage is to run ```shell # first tries `.air.toml` in current directory; if not found, uses defaults air ``` To use a specific config file explicitly, pass `-c`: ```shell air -c .air.toml ``` You can initialize the `.air.toml` configuration file to the current directory with the default settings running the following command. ```shell air init ``` After this, you can just run the `air` command without additional arguments, and it will use the `.air.toml` file for configuration. ```shell air ``` For modifying the configuration refer to the [air_example.toml](air_example.toml) file. ### Runtime arguments You can pass arguments for running the built binary by adding them after the air command. ```shell # Will run ./tmp/main bench air bench # Will run ./tmp/main server --port 8080 air server --port 8080 ``` You can separate the arguments passed for the air command and the built binary with `--` argument. ```shell # Will run ./tmp/main -h air -- -h # Will run air with custom config and pass -h argument to the built binary air -c .air.toml -- -h ``` ### Entrypoint Use `build.entrypoint` to point at the binary generated by `build.cmd` and describe how it should be executed. The value can be either a string (just the executable) or an array of strings. When using an array, the first element is the executable (resolved relative to `root` unless it lacks a path separator, in which case `$PATH` is consulted) and every subsequent element is treated as a default argument. Values from `build.args_bin` and the command line are appended after the inline arguments. The legacy `build.bin` field is deprecated and will be removed in a future release, so prefer the entrypoint form going forward. ```toml [build] entrypoint = ["./tmp/main"] args_bin = ["server", ":8080"] # Inline the default arguments directly after the binary. entrypoint = ["./tmp/main", "server", ":8080"] # Use PATH-resolved tools like dlv by omitting path separators. entrypoint = [ "dlv", "exec", "--accept-multiclient", "--log", "--headless", "--continue", "--listen=:8999", "--api-version", "2", "./tmp/main", ] ``` ### Environment Files Air can automatically load environment variables from `.env` files before both building and running when `env_files` is configured. ```toml # Loads .env.development and then .env files. # Values in the lattermost file overwrite any preceding ones. # Does not overwrite variables that were present before running air. env_files = [".env.development", ".env"] ``` ### Docker Compose ```yaml services: my-project-with-air: image: cosmtrek/air # working_dir value has to be the same of mapped volume working_dir: /project-package ports: - : environment: - ENV_A=${ENV_A} - ENV_B=${ENV_B} - ENV_C=${ENV_C} volumes: - ./project-relative-path/:/project-package/ ``` ### Debug `air -d` prints all logs. ## Installation and Usage for Docker users who don't want to use air image `Dockerfile` ```Dockerfile # Choose whatever you want, version >= 1.25 FROM golang:1.25-alpine WORKDIR /app RUN go install github.com/air-verse/air@latest COPY go.mod go.sum ./ RUN go mod download CMD ["air", "-c", ".air.toml"] ``` `docker-compose.yaml` ```yaml version: "3.8" services: web: build: context: . # Correct the path to your Dockerfile dockerfile: Dockerfile ports: - 8080:3000 # Important to bind/mount your codebase dir to /app dir for live reload volumes: - ./:/app ``` ## Q&A ### "command not found: air" or "No such file or directory" ```shell export GOPATH=$HOME/xxxxx export PATH=$PATH:$GOROOT/bin:$GOPATH/bin export PATH=$PATH:$(go env GOPATH)/bin #Confirm this line in your .profile and make sure to source the .profile if you add it!!! ``` ### Error under wsl when ' is included in the bin Should use `\` to escape the `'` in the bin. related issue: [#305](https://github.com/air-verse/air/issues/305) ### Question: how to do hot compile only and do not run anything? [#365](https://github.com/air-verse/air/issues/365) ```toml [build] cmd = "/usr/bin/true" ``` ### How to Reload the Browser Automatically on Static File Changes Refer to issue [#512](https://github.com/air-verse/air/issues/512) for additional details. - Ensure your static files in `include_dir`, `include_ext`, or `include_file`. - Ensure your HTML has a `` tag - Activate the proxy by configuring the following config: ```toml [proxy] enabled = true proxy_port = app_port = ``` ## Development Please note that it requires Go 1.25+ (see `go.mod`). ```shell # Fork this project # Clone it mkdir -p $GOPATH/src/github.com/cosmtrek cd $GOPATH/src/github.com/cosmtrek git clone git@github.com:/air.git # Install dependencies cd air make ci # Explore it and happy hacking! make install ``` Pull requests are welcome. ### Release ```shell # Checkout to master git checkout master # Add the version that needs to be released git tag v1.xx.x # Push to remote git push origin v1.xx.x # The CI will process and release a new version. Wait about 5 min, and you can fetch the latest version ``` ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=air-verse/air&type=Date)](https://star-history.com/#air-verse/air&Date) ## Sponsor [![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-orange.png)](https://www.buymeacoffee.com/cosmtrek) Give huge thanks to lots of supporters. I've always been remembering your kindness. ## License [GNU General Public License v3.0](LICENSE) ================================================ FILE: air_example.toml ================================================ #:schema https://json.schemastore.org/any.json # Config file for [Air](https://github.com/air-verse/air) in TOML format # Working directory # . or absolute path, please note that the directories following must be under root. root = "." tmp_dir = "tmp" # Remove to not load any files whatsoever # Non-existing files are safely ignored env_files = [".env"] [build] # Array of commands to run before each build pre_cmd = ["echo 'hello air' > pre_cmd.txt"] # Just plain old shell command. You could use `make` as well. cmd = "go build -o ./tmp/main ." # Array of commands to run after ^C post_cmd = ["echo 'hello air' > post_cmd.txt"] # Binary file yields from 'cmd', will be deprecated soon, recommend using entrypoint. bin = "tmp/main" # Entrypoint binary relative to root. First item is the executable, more items are default arguments. entrypoint = ["./tmp/main"] # Customize binary, can setup environment variables when run your app. full_bin = "APP_ENV=dev APP_USER=air ./tmp/main" # Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'. args_bin = ["hello", "world"] # Watch these filename extensions. Use ["*"] to watch all file extensions. include_ext = ["go", "tpl", "tmpl", "html"] # Ignore these filename extensions or directories. exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules"] # Watch these directories if you specified. include_dir = [] # Watch these files. include_file = [] # Exclude files. exclude_file = [] # Exclude specific regular expressions. exclude_regex = ["_test\\.go"] # Exclude unchanged files. exclude_unchanged = true # Ignore dangerous root directory that could cause excessive file watching ignore_dangerous_root_dir = false # Follow symlink for directories follow_symlink = true # This log file is placed in your tmp_dir. log = "air.log" # Poll files for changes instead of using fsnotify. poll = false # Poll interval (defaults to the minimum interval of 500ms). poll_interval = 500 # ms # It's not necessary to trigger build each time file changes if it's too frequent. delay = 0 # ms # Stop running old binary when build errors occur. stop_on_error = true # Send Interrupt signal before killing process (ignored on Windows; uses TASKKILL) send_interrupt = false # Delay after sending Interrupt signal kill_delay = 500 # nanosecond # Rerun binary or not rerun = false # Delay after each execution rerun_delay = 500 [log] # Show log time time = false # Only show main log (silences watcher, build, runner) main_only = false # silence all logs produced by air silent = false [color] # Customize each part's color. If no color found, use the raw app log. main = "magenta" watcher = "cyan" build = "yellow" runner = "green" [misc] # Delete tmp directory on exit clean_on_exit = true [screen] clear_on_rebuild = true keep_scroll = true [proxy] # Enable live-reloading on the browser. enabled = true proxy_port = 8090 app_port = 8080 # Timeout in milliseconds for waiting for the app to start and become available. # Useful when your app has slow startup time (e.g., database connections, config loading). # The proxy will retry connecting to your app for this duration before giving up. # Default is 5000ms (5 seconds). Increase this if you see "unable to reach app" errors. app_start_timeout = 5000 ================================================ FILE: go.mod ================================================ module github.com/air-verse/air go 1.25 require ( dario.cat/mergo v1.0.2 github.com/andybalholm/brotli v1.2.0 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gohugoio/hugo v0.149.1 github.com/joho/godotenv v1.5.1 github.com/pelletier/go-toml v1.9.5 github.com/stretchr/testify v1.11.1 golang.org/x/sys v0.35.0 ) require ( github.com/bep/debounce v1.2.1 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect github.com/bep/gowebp v0.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/tdewolff/parse/v2 v2.8.3 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc= github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo= github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA= github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c= github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg= github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI= github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA= github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw= github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg= github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q= github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc= github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw= github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044= github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k= github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8= github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8= github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk= github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ= github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0= github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE= github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro= github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs= github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ= github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs= github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI= github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio= github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o= github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE= github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE= github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY= github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog= github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc= github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo= github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE= github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I= github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= ================================================ FILE: hack/check.sh ================================================ #!/usr/bin/env bash readonly reset=$(tput sgr0) readonly red=$(tput bold; tput setaf 1) readonly green=$(tput bold; tput setaf 2) exit_code=0 check_scope=$1 if [[ "${check_scope}" = "all" ]]; then echo "all" files=($(git ls-files | grep "\.go$" | grep -v -e "^third_party" -e "^vendor")) else files=($(git diff --cached --name-only --diff-filter ACM | grep "\.go$" | grep -v -e "^third_party" -e "^vendor")) fi echo -e "${green}1. Formatting code style" if [[ "${#files[@]}" -ne 0 ]]; then goimports -w ${files[@]} fi echo -e "${green}2. Linting" if ! command -v golangci-lint &> /dev/null; then echo "${red}golangci-lint command not found. Please install it first." exit_code=1 else # If golangci-lint was built with an older Go than the module target, hint to upgrade lint_go_ver=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9]\+\.[0-9]\+\).*/\1/p') mod_go_ver=$(sed -n 's/^go \([0-9]\+\.[0-9]\+\).*/\1/p' go.mod | head -n1) if [[ -n "${lint_go_ver}" && -n "${mod_go_ver}" ]]; then lint_minor=$(echo "${lint_go_ver}" | cut -d. -f2) mod_minor=$(echo "${mod_go_ver}" | cut -d. -f2) if [[ ${lint_minor} -lt ${mod_minor} ]]; then echo "${red}can't load config: the Go language version (go${lint_go_ver}) used to build golangci-lint is lower than the targeted Go version (${mod_go_ver})" echo "${red}Hint: upgrade golangci-lint (run: make init)" exit_code=1 fi fi if [[ ${exit_code} -eq 0 ]] && ! golangci-lint run; then echo "${red}Linting issues found." exit_code=1 fi fi if [[ ${exit_code} -ne 0 ]]; then echo "${red}Please fix the errors above :)" else echo "${green}Nice!" fi echo "${reset}" exit ${exit_code} ================================================ FILE: hooks/pre-commit ================================================ #!/usr/bin/env bash set -e ./hack/check.sh exit $? ================================================ FILE: install.sh ================================================ #!/bin/sh set -e # Code generated by godownloader on 2020-08-12T16:16:22Z. DO NOT EDIT. # usage() { this=$1 cat </dev/null } echoerr() { echo "$@" 1>&2 } log_prefix() { echo "$0" } _logp=6 log_set_priority() { _logp="$1" } log_priority() { if test -z "$1"; then echo "$_logp" return fi [ "$1" -le "$_logp" ] } log_tag() { case $1 in 0) echo "emerg" ;; 1) echo "alert" ;; 2) echo "crit" ;; 3) echo "err" ;; 4) echo "warning" ;; 5) echo "notice" ;; 6) echo "info" ;; 7) echo "debug" ;; *) echo "$1" ;; esac } log_debug() { log_priority 7 || return 0 echoerr "$(log_prefix)" "$(log_tag 7)" "$@" } log_info() { log_priority 6 || return 0 echoerr "$(log_prefix)" "$(log_tag 6)" "$@" } log_err() { log_priority 3 || return 0 echoerr "$(log_prefix)" "$(log_tag 3)" "$@" } log_crit() { log_priority 2 || return 0 echoerr "$(log_prefix)" "$(log_tag 2)" "$@" } uname_os() { os=$(uname -s | tr '[:upper:]' '[:lower:]') case "$os" in cygwin_nt*) os="windows" ;; mingw*) os="windows" ;; msys_nt*) os="windows" ;; esac echo "$os" } uname_arch() { arch=$(uname -m) case $arch in x86_64) arch="amd64" ;; x86) arch="386" ;; i686) arch="386" ;; i386) arch="386" ;; aarch64) arch="arm64" ;; armv5*) arch="armv5" ;; armv6*) arch="armv6" ;; armv7*) arch="armv7" ;; esac echo ${arch} } uname_os_check() { os=$(uname_os) case "$os" in darwin) return 0 ;; dragonfly) return 0 ;; freebsd) return 0 ;; linux) return 0 ;; android) return 0 ;; nacl) return 0 ;; netbsd) return 0 ;; openbsd) return 0 ;; plan9) return 0 ;; solaris) return 0 ;; windows) return 0 ;; esac log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" return 1 } uname_arch_check() { arch=$(uname_arch) case "$arch" in 386) return 0 ;; amd64) return 0 ;; arm64) return 0 ;; armv5) return 0 ;; armv6) return 0 ;; armv7) return 0 ;; ppc64) return 0 ;; ppc64le) return 0 ;; mips) return 0 ;; mipsle) return 0 ;; mips64) return 0 ;; mips64le) return 0 ;; s390x) return 0 ;; amd64p32) return 0 ;; esac log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" return 1 } untar() { tarball=$1 case "${tarball}" in *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; *.tar) tar --no-same-owner -xf "${tarball}" ;; *.zip) unzip "${tarball}" ;; *) log_err "untar unknown archive format for ${tarball}" return 1 ;; esac } http_download_curl() { local_file=$1 source_url=$2 header=$3 if [ -z "$header" ]; then code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") else code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") fi if [ "$code" != "200" ]; then log_debug "http_download_curl received HTTP status $code" return 1 fi return 0 } http_download_wget() { local_file=$1 source_url=$2 header=$3 if [ -z "$header" ]; then wget -q -O "$local_file" "$source_url" else wget -q --header "$header" -O "$local_file" "$source_url" fi } http_download() { log_debug "http_download $2" if is_command curl; then http_download_curl "$@" return elif is_command wget; then http_download_wget "$@" return fi log_crit "http_download unable to find wget or curl" return 1 } http_copy() { tmp=$(mktemp) http_download "${tmp}" "$1" "$2" || return 1 body=$(cat "$tmp") rm -f "${tmp}" echo "$body" } github_release() { owner_repo=$1 version=$2 test -z "$version" && version="latest" giturl="https://github.com/${owner_repo}/releases/${version}" json=$(http_copy "$giturl" "Accept:application/json") test -z "$json" && return 1 version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') test -z "$version" && return 1 echo "$version" } hash_sha256() { TARGET=${1:-/dev/stdin} if is_command gsha256sum; then hash=$(gsha256sum "$TARGET") || return 1 echo "$hash" | cut -d ' ' -f 1 elif is_command sha256sum; then hash=$(sha256sum "$TARGET") || return 1 echo "$hash" | cut -d ' ' -f 1 elif is_command shasum; then hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 echo "$hash" | cut -d ' ' -f 1 elif is_command openssl; then hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 echo "$hash" | cut -d ' ' -f a else log_crit "hash_sha256 unable to find command to compute sha-256 hash" return 1 fi } hash_sha256_verify() { TARGET=$1 checksums=$2 if [ -z "$checksums" ]; then log_err "hash_sha256_verify checksum file not specified in arg2" return 1 fi BASENAME=${TARGET##*/} want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) if [ -z "$want" ]; then log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" return 1 fi got=$(hash_sha256 "$TARGET") if [ "$want" != "$got" ]; then log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" return 1 fi } cat /dev/null <> pid ./_testdata/grandchild.sh background & ./_testdata/grandchild.sh foreground echo "ProcessID=$$ ends ($0)" ================================================ FILE: runner/_testdata/grandchild.sh ================================================ #!/bin/sh echo "ProcessID=$$ begins ($0)" echo "$$" >> pid sleep 9999 echo "ProcessID=$$ ends ($0)" ================================================ FILE: runner/_testdata/invalid_toml/.air.toml ================================================ # Config file with duplicate key to trigger parse error root = "." [build] delay = 1000 delay = 2000 ================================================ FILE: runner/_testdata/run-detached-process.sh ================================================ #!/bin/sh echo "ProcessID=$$ begins ($0)" echo "$$" >> pid setsid sh -c './_testdata/grandchild.sh detached' >/dev/null 2>&1 & DETACHED_PID=$! echo "$DETACHED_PID" >> pid sleep 9999 echo "ProcessID=$$ ends ($0)" ================================================ FILE: runner/_testdata/run-many-processes.sh ================================================ #!/bin/sh echo "ProcessID=$$ begins ($0)" ./_testdata/child.sh background & ./_testdata/child.sh foreground echo "ProcessID=$$ ends ($0)" ================================================ FILE: runner/_testdata/toml/.air.toml ================================================ #:schema https://json.schemastore.org/any.json root = "toml_root" ================================================ FILE: runner/_testdata/watching/inner/test.txt ================================================ ================================================ FILE: runner/cmdarg_test.go ================================================ package runner ================================================ FILE: runner/common.go ================================================ package runner const ( //PlatformWindows const for windows PlatformWindows = "windows" ) ================================================ FILE: runner/config.go ================================================ package runner import ( "errors" "flag" "fmt" "os" "os/exec" "path/filepath" "reflect" "regexp" "runtime" "strings" "time" "dario.cat/mergo" "github.com/pelletier/go-toml" ) const ( dftTOML = ".air.toml" airWd = "air_wd" defaultProxyAppStartTimeout = 5000 schemaHeader = "#:schema https://json.schemastore.org/any.json" ) // Config is the main configuration structure for Air. type Config struct { Root string `toml:"root" usage:"Working directory, . or absolute path, please note that the directories following must be under root"` TmpDir string `toml:"tmp_dir" usage:"Temporary directory for air"` TestDataDir string `toml:"testdata_dir"` EnvFiles []string `toml:"env_files" usage:"Paths to .env files to load before build/run"` Build cfgBuild `toml:"build"` Color cfgColor `toml:"color"` Log cfgLog `toml:"log"` Misc cfgMisc `toml:"misc"` Screen cfgScreen `toml:"screen"` Proxy cfgProxy `toml:"proxy"` } type entrypoint []string func (e *entrypoint) UnmarshalTOML(v interface{}) error { switch val := v.(type) { case nil: *e = nil return nil case string: *e = []string{val} return nil case []interface{}: values := make([]string, len(val)) for i, raw := range val { s, ok := raw.(string) if !ok { return fmt.Errorf("entrypoint values must be strings, got %T", raw) } values[i] = s } *e = values return nil default: return fmt.Errorf("entrypoint must be a string or array of strings, got %T", v) } } func (e entrypoint) binary() string { if len(e) == 0 { return "" } return e[0] } func (e entrypoint) args() []string { if len(e) <= 1 { return nil } return e[1:] } type cfgBuild struct { PreCmd []string `toml:"pre_cmd" usage:"Array of commands to run before each build"` Cmd string `toml:"cmd" usage:"Just plain old shell command. You could use 'make' as well"` PostCmd []string `toml:"post_cmd" usage:"Array of commands to run after ^C"` Bin string `toml:"bin" usage:"Binary file yields from 'cmd', will be deprecated soon, recommend using entrypoint."` Entrypoint entrypoint `toml:"entrypoint" usage:"Binary file plus optional arguments relative to root, prefer [\"./tmp/main\", \"arg\"] form"` FullBin string `toml:"full_bin" usage:"Customize binary, can setup environment variables when run your app"` ArgsBin []string `toml:"args_bin" usage:"Add additional arguments when running binary (bin/full_bin)."` Log string `toml:"log" usage:"This log file is placed in your tmp_dir"` IncludeExt []string `toml:"include_ext" usage:"Watch these filename extensions"` ExcludeDir []string `toml:"exclude_dir" usage:"Ignore these filename extensions or directories"` IncludeDir []string `toml:"include_dir" usage:"Watch these directories if you specified"` ExcludeFile []string `toml:"exclude_file" usage:"Exclude files"` IncludeFile []string `toml:"include_file" usage:"Watch these files"` ExcludeRegex []string `toml:"exclude_regex" usage:"Exclude specific regular expressions"` ExcludeUnchanged bool `toml:"exclude_unchanged" usage:"Exclude unchanged files"` IgnoreDangerousRootDir bool `toml:"ignore_dangerous_root_dir" usage:"Ignore dangerous root directory that could cause excessive file watching"` FollowSymlink bool `toml:"follow_symlink" usage:"Follow symlink for directories"` Poll bool `toml:"poll" usage:"Poll files for changes instead of using fsnotify"` PollInterval int `toml:"poll_interval" usage:"Poll interval (defaults to the minimum interval of 500ms)"` Delay int `toml:"delay" usage:"It's not necessary to trigger build each time file changes if it's too frequent"` StopOnError bool `toml:"stop_on_error" usage:"Stop running old binary when build errors occur"` SendInterrupt bool `toml:"send_interrupt" usage:"Send Interrupt signal before killing process (ignored on Windows; uses TASKKILL)"` KillDelay time.Duration `toml:"kill_delay" usage:"Delay after sending Interrupt signal"` Rerun bool `toml:"rerun" usage:"Rerun binary or not"` RerunDelay int `toml:"rerun_delay" usage:"Delay after each execution"` regexCompiled []*regexp.Regexp includeDirAbs []string extraIncludeDirs []string } func (c *cfgBuild) RegexCompiled() ([]*regexp.Regexp, error) { return c.regexCompiled, nil } func (c *cfgBuild) normalizeIncludeDirs(root string) { c.includeDirAbs = c.includeDirAbs[:0] c.extraIncludeDirs = c.extraIncludeDirs[:0] if root == "" { return } for _, dir := range c.IncludeDir { dir = cleanPath(dir) if dir == "" { continue } dir = filepath.Clean(dir) abs := dir if !filepath.IsAbs(dir) { abs = filepath.Join(root, dir) } abs = filepath.Clean(abs) if isSubPath(root, abs) { c.includeDirAbs = append(c.includeDirAbs, abs) continue } c.extraIncludeDirs = append(c.extraIncludeDirs, abs) } } type cfgLog struct { AddTime bool `toml:"time" usage:"Show log time"` MainOnly bool `toml:"main_only" usage:"Only show main log (silences watcher, build, runner)"` Silent bool `toml:"silent" usage:"silence all logs produced by air"` } type cfgColor struct { Main string `toml:"main" usage:"Customize main part's color. If no color found, use the raw app log"` Watcher string `toml:"watcher" usage:"Customize watcher part's color"` Build string `toml:"build" usage:"Customize build part's color"` Runner string `toml:"runner" usage:"Customize runner part's color"` App string `toml:"app"` } type cfgMisc struct { CleanOnExit bool `toml:"clean_on_exit" usage:"Delete tmp directory on exit"` } type cfgScreen struct { ClearOnRebuild bool `toml:"clear_on_rebuild" usage:"Clear screen on rebuild"` KeepScroll bool `toml:"keep_scroll" usage:"Keep scroll position after rebuild"` } type cfgProxy struct { Enabled bool `toml:"enabled" usage:"Enable live-reloading on the browser"` ProxyPort int `toml:"proxy_port" usage:"Port for proxy server"` AppPort int `toml:"app_port" usage:"Port for your app"` AppStartTimeout int `toml:"app_start_timeout" usage:"Timeout for waiting for app to start in milliseconds (default 5000)"` } type sliceTransformer struct{} func (t sliceTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { if typ.Kind() == reflect.Slice { return func(dst, src reflect.Value) error { if !src.IsZero() { dst.Set(src) } return nil } } return nil } // InitConfig initializes the configuration. func InitConfig(path string, cmdArgs map[string]TomlInfo) (cfg *Config, err error) { if path == "" { cfg, err = defaultPathConfig() if err != nil { return nil, err } } else { cfg, err = readConfigOrDefault(path) if err != nil { return nil, err } } warnDeprecatedBin(cfg) config := defaultConfig() // get addr ret := &config err = mergo.Merge(ret, cfg, func(config *mergo.Config) { // mergo.Merge will overwrite the fields if it is Empty // So need use this to avoid that none-zero slice will be overwritten. // https://dario.cat/mergo#transformers config.Transformers = sliceTransformer{} config.Overwrite = true }) if err != nil { return nil, err } err = ret.preprocess(cmdArgs) return ret, err } func writeDefaultConfig() (string, error) { fstat, err := os.Stat(dftTOML) if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("failed to check for existing configuration: %w", err) } if err == nil && fstat != nil { return "", errors.New("configuration already exists") } file, err := os.Create(dftTOML) if err != nil { return "", fmt.Errorf("failed to create a new configuration: %w", err) } defer file.Close() config := defaultConfig() if len(config.Build.Entrypoint) == 0 && config.Build.Bin != "" { config.Build.Entrypoint = entrypoint{config.Build.Bin} } configFile, err := toml.Marshal(config) if err != nil { return "", fmt.Errorf("failed to marshal the default configuration: %w", err) } headers := []byte(schemaHeader + "\n\n") content := append(headers, configFile...) _, err = file.Write(content) if err != nil { return "", fmt.Errorf("failed to write to %s: %w", dftTOML, err) } return dftTOML, nil } func defaultPathConfig() (*Config, error) { // when path is blank, first find `.air.toml` in `air_wd` and current working directory, if not found, use defaults cfg, err := readConfByName(dftTOML) if err == nil { return cfg, nil } // If the config file exists but failed to parse, report the error // Only use defaults if no config file exists if !os.IsNotExist(err) { return nil, fmt.Errorf("failed to parse %s: %w", dftTOML, err) } dftCfg := defaultConfig() return &dftCfg, nil } func readConfByName(name string) (*Config, error) { var path string if wd := os.Getenv(airWd); wd != "" { path = filepath.Join(wd, name) } else { wd, err := os.Getwd() if err != nil { return nil, err } path = filepath.Join(wd, name) } cfg, err := readConfig(path) return cfg, err } func defaultConfig() Config { build := cfgBuild{ Cmd: "go build -o ./tmp/main .", Bin: "./tmp/main", Entrypoint: entrypoint{}, Log: "build-errors.log", IncludeExt: []string{"go", "tpl", "tmpl", "html"}, IncludeDir: []string{}, PreCmd: []string{}, PostCmd: []string{}, ExcludeFile: []string{}, IncludeFile: []string{}, ExcludeDir: []string{"assets", "tmp", "vendor", "testdata"}, ArgsBin: []string{}, ExcludeRegex: []string{"_test.go"}, Delay: 1000, Rerun: false, RerunDelay: 500, } if runtime.GOOS == PlatformWindows { build.Bin = `tmp\main.exe` build.Cmd = "go build -o ./tmp/main.exe ." } log := cfgLog{ AddTime: false, MainOnly: false, Silent: false, } color := cfgColor{ Main: "magenta", Watcher: "cyan", Build: "yellow", Runner: "green", } misc := cfgMisc{ CleanOnExit: false, } return Config{ Root: ".", TmpDir: "tmp", TestDataDir: "testdata", EnvFiles: []string{}, Build: build, Color: color, Log: log, Misc: misc, Screen: cfgScreen{ ClearOnRebuild: false, KeepScroll: true, }, } } func readConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } cfg := new(Config) if err = toml.Unmarshal(data, cfg); err != nil { return nil, err } return cfg, nil } func readConfigOrDefault(path string) (*Config, error) { dftCfg := defaultConfig() cfg, err := readConfig(path) if err != nil { return &dftCfg, err } return cfg, nil } func (c *Config) preprocess(args map[string]TomlInfo) error { var err error if args != nil { c.withArgs(args) } cwd := os.Getenv(airWd) if cwd != "" { if err = os.Chdir(cwd); err != nil { return err } c.Root = cwd } c.Root, err = expandPath(c.Root) if err != nil { return err } // Check for dangerous root directories that could cause excessive file watching if isDangerous, dirName := isDangerousRoot(c.Root); isDangerous { if !c.Build.IgnoreDangerousRootDir { return fmt.Errorf("refusing to run in %s - this would watch too many files. Please run air in a project directory", dirName) } fmt.Fprintln(os.Stdout, "[warning] ignoring root directory protections. This could cause excessive file watching. It is recommended to run air in a project directory") } if c.TmpDir == "" { c.TmpDir = "tmp" } if c.TestDataDir == "" { c.TestDataDir = "testdata" } ed := c.Build.ExcludeDir for i := range ed { ed[i] = cleanPath(ed[i]) } if len(c.Build.Entrypoint) > 0 { entry := c.Build.Entrypoint.binary() if !filepath.IsAbs(entry) { if resolved := resolveCommandPath(entry); resolved != "" { entry = resolved } else { entry = joinPath(c.Root, entry) } } entry, err = filepath.Abs(entry) if err != nil { return err } c.Build.Entrypoint[0] = entry } adaptToVariousPlatforms(c) c.Build.normalizeIncludeDirs(c.Root) // Join runtime arguments with the configuration arguments runtimeArgs := flag.Args() c.Build.ArgsBin = append(c.Build.ArgsBin, runtimeArgs...) // Compile the exclude regexes if there are any patterns in the config file if len(c.Build.ExcludeRegex) > 0 { regexCompiled := make([]*regexp.Regexp, len(c.Build.ExcludeRegex)) for idx, expr := range c.Build.ExcludeRegex { re, err := regexp.Compile(expr) if err != nil { return fmt.Errorf("failed to compile regex %s", expr) } regexCompiled[idx] = re } c.Build.regexCompiled = regexCompiled } c.Build.ExcludeDir = ed if len(c.Build.FullBin) > 0 { c.Build.Bin = c.Build.FullBin return err } // Fix windows CMD processor // CMD will not recognize relative path like ./tmp/server c.Build.Bin, err = filepath.Abs(c.Build.Bin) return err } func (c *Config) colorInfo() map[string]string { return map[string]string{ "main": c.Color.Main, "build": c.Color.Build, "runner": c.Color.Runner, "watcher": c.Color.Watcher, } } func (c *Config) buildLogPath() string { return joinPath(c.tmpPath(), c.Build.Log) } func (c *Config) buildDelay() time.Duration { return time.Duration(c.Build.Delay) * time.Millisecond } func (c *Config) rerunDelay() time.Duration { return time.Duration(c.Build.RerunDelay) * time.Millisecond } func (c *Config) killDelay() time.Duration { // kill_delay can be specified as an integer or duration string // interpret as milliseconds if less than the value of 1 millisecond if c.Build.KillDelay < time.Millisecond { return c.Build.KillDelay * time.Millisecond } // normalize kill delay to milliseconds return time.Duration(c.Build.KillDelay.Milliseconds()) * time.Millisecond } func (c *Config) binPath() string { if len(c.Build.Entrypoint) > 0 { return c.Build.Entrypoint.binary() } return joinPath(c.Root, c.Build.Bin) } func (c *Config) runnerBin() string { if len(c.Build.Entrypoint) > 0 && len(c.Build.FullBin) == 0 { return c.Build.Entrypoint.binary() } return c.Build.Bin } func (c *Config) tmpPath() string { return joinPath(c.Root, c.TmpDir) } func (c *Config) testDataPath() string { return joinPath(c.Root, c.TestDataDir) } func (c *Config) rel(path string) string { s, err := filepath.Rel(c.Root, path) if err != nil { return "" } return s } func resolveCommandPath(entry string) string { if entry == "" || strings.ContainsAny(entry, `/\`) { return "" } path, err := exec.LookPath(entry) if err != nil { return "" } return path } // withArgs returns a new config with the given arguments added to the configuration. func (c *Config) withArgs(args map[string]TomlInfo) { for _, value := range args { // Ignore values that match the default configuration. // This ensures user-specified configurations are not overwritten by default values. if value.Value != nil && *value.Value != value.fieldValue { v := reflect.ValueOf(c) setValue2Struct(v, value.fieldPath, *value.Value) } } } func warnDeprecatedBin(cfg *Config) { if cfg == nil { return } if cfg.Build.Bin == "" || len(cfg.Build.Entrypoint) > 0 { return } fmt.Fprintln(os.Stdout, "[warning] build.bin is deprecated; set build.entrypoint instead") } ================================================ FILE: runner/config_test.go ================================================ package runner import ( "flag" "io" "os" "path/filepath" "reflect" "runtime" "strings" "testing" "time" ) const ( bin = `./tmp/main` cmd = "go build -o ./tmp/main ." ) func getWindowsConfig() Config { build := cfgBuild{ PreCmd: []string{"echo Hello Air"}, Cmd: "go build -o ./tmp/main .", Bin: "./tmp/main", Log: "build-errors.log", IncludeExt: []string{"go", "tpl", "tmpl", "html"}, ExcludeDir: []string{"assets", "tmp", "vendor", "testdata"}, ExcludeRegex: []string{"_test.go"}, Delay: 1000, StopOnError: true, } if runtime.GOOS == "windows" { build.Bin = bin build.Cmd = cmd } return Config{ Root: ".", TmpDir: "tmp", TestDataDir: "testdata", Build: build, } } func TestBinCmdPath(t *testing.T) { t.Parallel() var err error c := getWindowsConfig() err = c.preprocess(nil) if err != nil { t.Fatal(err) } if runtime.GOOS == "windows" { if strings.HasSuffix(c.Build.Bin, "exe") { t.Fail() } if strings.Contains(c.Build.Bin, "exe") { t.Fail() } } else { if strings.HasSuffix(c.Build.Bin, "exe") { t.Fail() } if strings.Contains(c.Build.Bin, "exe") { t.Fail() } } } func TestDefaultPathConfig(t *testing.T) { tests := []struct { name string path string root string }{{ name: "Invalid Path", path: "invalid/path", root: ".", }, { name: "TOML", path: "_testdata/toml", root: "toml_root", }} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv(airWd, tt.path) c, err := defaultPathConfig() if err != nil { t.Fatalf("Should not be fail: %s.", err) } if got, want := c.Root, tt.root; got != want { t.Fatalf("Root is %s, but want %s.", got, want) } }) } } func TestReadConfByName(t *testing.T) { _ = os.Unsetenv(airWd) config, _ := readConfByName(dftTOML) if config != nil { t.Fatalf("expect Config is nil,but get a not nil Config") } } func TestDefaultPathConfigWithInvalidTOML(t *testing.T) { // Test that defaultPathConfig returns an error when .air.toml exists but has parse errors // This is a regression test for issue #678 t.Setenv(airWd, "_testdata/invalid_toml") _, err := defaultPathConfig() if err == nil { t.Fatal("expected error when .air.toml has parse errors, but got nil") } if !strings.Contains(err.Error(), "failed to parse") { t.Fatalf("expected error message to contain 'failed to parse', got: %s", err.Error()) } if !strings.Contains(err.Error(), "defined twice") { t.Fatalf("expected error message to contain 'defined twice', got: %s", err.Error()) } } func TestConfPreprocess(t *testing.T) { t.Setenv(airWd, "_testdata/toml") originalDir, err := os.Getwd() if err != nil { t.Fatalf("failed to getwd: %v", err) } t.Cleanup(func() { if err := os.Chdir(originalDir); err != nil { t.Fatalf("failed to restore working directory: %v", err) } }) df := defaultConfig() err = df.preprocess(nil) if err != nil { t.Fatalf("preprocess error %v", err) } suffix := filepath.Join("_testdata", "toml", "tmp", "main") if runtime.GOOS == "windows" { suffix += ".exe" } binPath := df.Build.Bin if !strings.HasSuffix(binPath, suffix) { t.Fatalf("bin path is %s, but not have suffix %s.", binPath, suffix) } } func TestEntrypointResolvesAbsolutePath(t *testing.T) { t.Parallel() base := t.TempDir() rootWithSpace := filepath.Join(base, "with space") if err := os.MkdirAll(filepath.Join(rootWithSpace, "tmp"), 0o755); err != nil { t.Fatalf("failed to prepare tmp dir: %v", err) } cfg := defaultConfig() cfg.Root = rootWithSpace cfg.Build.Entrypoint = entrypoint{"./tmp/main"} if err := cfg.preprocess(nil); err != nil { t.Fatalf("preprocess error %v", err) } want := filepath.Join(rootWithSpace, "tmp", "main") if got := cfg.Build.Entrypoint.binary(); got != want { t.Fatalf("entrypoint is %s, but want %s", got, want) } if cfg.binPath() != want { t.Fatalf("bin path is %s, but want %s", cfg.binPath(), want) } } func TestEntrypointResolvesFromPath(t *testing.T) { root := t.TempDir() pathDir := t.TempDir() binName := "air-entrypoint-path" fileName := binName fileContents := "#!/bin/sh\nexit 0\n" if runtime.GOOS == "windows" { fileName += ".bat" fileContents = "@echo off\r\n" t.Setenv("PATHEXT", ".BAT;.EXE") } fullPath := filepath.Join(pathDir, fileName) if err := os.WriteFile(fullPath, []byte(fileContents), 0o755); err != nil { t.Fatalf("failed to write fake binary: %v", err) } if runtime.GOOS != "windows" { if err := os.Chmod(fullPath, 0o755); err != nil { t.Fatalf("failed to make fake binary executable: %v", err) } } t.Setenv("PATH", pathDir+string(os.PathListSeparator)+os.Getenv("PATH")) cfg := defaultConfig() cfg.Root = root cfg.Build.Entrypoint = entrypoint{binName} if err := cfg.preprocess(nil); err != nil { t.Fatalf("preprocess error %v", err) } want := fullPath if got := cfg.Build.Entrypoint.binary(); got != want { t.Fatalf("entrypoint resolved to %s, want %s", got, want) } } func TestEntrypointPreservesArgs(t *testing.T) { t.Parallel() root := t.TempDir() cfg := defaultConfig() cfg.Root = root cfg.Build.Entrypoint = entrypoint{"./tmp/main", "server", ":8080"} if err := cfg.preprocess(nil); err != nil { t.Fatalf("preprocess error %v", err) } wantBin := filepath.Join(root, "tmp", "main") if cfg.Build.Entrypoint.binary() != wantBin { t.Fatalf("entrypoint binary is %s, want %s", cfg.Build.Entrypoint.binary(), wantBin) } wantArgs := []string{"server", ":8080"} if got := cfg.Build.Entrypoint.args(); !reflect.DeepEqual(got, wantArgs) { t.Fatalf("entrypoint args mismatch, got %v want %v", got, wantArgs) } } func TestConfigWithRuntimeArgs(t *testing.T) { runtimeArg := "-flag=value" // inject runtime arg oldArgs := os.Args defer func() { os.Args = oldArgs flag.Parse() }() os.Args = []string{"air", "--", runtimeArg} flag.Parse() t.Run("when using bin", func(t *testing.T) { df := defaultConfig() if err := df.preprocess(nil); err != nil { t.Fatalf("preprocess error %v", err) } if !contains(df.Build.ArgsBin, runtimeArg) { t.Fatalf("missing expected runtime arg: %s", runtimeArg) } }) t.Run("when using full_bin", func(t *testing.T) { df := defaultConfig() df.Build.FullBin = "./tmp/main" if err := df.preprocess(nil); err != nil { t.Fatalf("preprocess error %v", err) } if !contains(df.Build.ArgsBin, runtimeArg) { t.Fatalf("missing expected runtime arg: %s", runtimeArg) } }) } func TestReadConfigWithWrongPath(t *testing.T) { t.Parallel() c, err := readConfig("xxxx") if err == nil { t.Fatal("need throw a error") } if c != nil { t.Fatal("expect is nil but got a conf") } } func TestKillDelay(t *testing.T) { t.Parallel() config := Config{ Build: cfgBuild{ KillDelay: 1000, }, } if config.killDelay() != (1000 * time.Millisecond) { t.Fatal("expect KillDelay 1000 to be interpreted as 1000 milliseconds, got ", config.killDelay()) } config.Build.KillDelay = 1 if config.killDelay() != (1 * time.Millisecond) { t.Fatal("expect KillDelay 1 to be interpreted as 1 millisecond, got ", config.killDelay()) } config.Build.KillDelay = 1_000_000 if config.killDelay() != (1 * time.Millisecond) { t.Fatal("expect KillDelay 1_000_000 to be interpreted as 1 millisecond, got ", config.killDelay()) } config.Build.KillDelay = 100_000_000 if config.killDelay() != (100 * time.Millisecond) { t.Fatal("expect KillDelay 100_000_000 to be interpreted as 100 milliseconds, got ", config.killDelay()) } config.Build.KillDelay = 0 if config.killDelay() != 0 { t.Fatal("expect KillDelay 0 to be interpreted as 0, got ", config.killDelay()) } } func contains(sl []string, target string) bool { for _, c := range sl { if c == target { return true } } return false } func TestWarnDeprecatedBin(t *testing.T) { t.Parallel() tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, ".air.toml") cfgContent := ` [build] bin = "./tmp/main" cmd = "go build -o ./tmp/main ." ` if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { t.Fatalf("failed to write config: %v", err) } oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w _, _ = InitConfig(cfgPath, nil) if err := w.Close(); err != nil { t.Fatalf("failed to close writer: %v", err) } os.Stdout = oldStdout out, err := io.ReadAll(r) if err != nil { t.Fatalf("failed to read output: %v", err) } output := string(out) if !strings.Contains(output, "build.bin is deprecated") { t.Fatalf("missing bin deprecation warning in output: %q", output) } } func TestWarnIgnoreDangerousRootDirProtection(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("root dir protection uses Unix root path") } tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) t.Run("when ignore_dangerous_root_dir is true", func(t *testing.T) { cfgPath := filepath.Join(tmpDir, ".air.toml") cfgContent := ` root = "/" [build] entrypoint = "tmp/main" cmd = "go build -o ./tmp/main ." ignore_dangerous_root_dir = true ` if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { t.Fatalf("failed to write config: %v", err) } oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w _, _ = InitConfig(cfgPath, nil) if err := w.Close(); err != nil { t.Fatalf("failed to close writer: %v", err) } os.Stdout = oldStdout out, err := io.ReadAll(r) if err != nil { t.Fatalf("failed to read output: %v", err) } output := string(out) if !strings.Contains(output, "ignoring root directory protections. This could cause excessive file watching. It is recommended to run air in a project directory") { t.Fatalf("missing root directory protection warning in output: %q", output) } }) t.Run("when ignore_dangerous_root_dir is false", func(t *testing.T) { cfgPath := filepath.Join(tmpDir, ".air.toml") cfgContent := ` root = "/" [build] entrypoint = "tmp/main" cmd = "go build -o ./tmp/main ." ignore_dangerous_root_dir = false ` if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { t.Fatalf("failed to write config: %v", err) } oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w _, _ = InitConfig(cfgPath, nil) if err := w.Close(); err != nil { t.Fatalf("failed to close writer: %v", err) } os.Stdout = oldStdout out, err := io.ReadAll(r) if err != nil { t.Fatalf("failed to read output: %v", err) } output := string(out) if strings.Contains(output, "ignoring root directory protections") { t.Fatalf("unexpected root directory protection warning in output: %q", output) } }) t.Run("when ignore_dangerous_root_dir is not set", func(t *testing.T) { cfgPath := filepath.Join(tmpDir, ".air.toml") cfgContent := ` root = "/" [build] entrypoint = "tmp/main" cmd = "go build -o ./tmp/main ." ` if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil { t.Fatalf("failed to write config: %v", err) } oldStdout := os.Stdout r, w, err := os.Pipe() if err != nil { t.Fatalf("failed to create pipe: %v", err) } os.Stdout = w _, _ = InitConfig(cfgPath, nil) if err := w.Close(); err != nil { t.Fatalf("failed to close writer: %v", err) } os.Stdout = oldStdout out, err := io.ReadAll(r) if err != nil { t.Fatalf("failed to read output: %v", err) } output := string(out) if strings.Contains(output, "ignoring root directory protections") { t.Fatalf("unexpected root directory protection warning in output: %q", output) } }) } ================================================ FILE: runner/engine.go ================================================ package runner import ( "fmt" "io" "log" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/gohugoio/hugo/watcher/filenotify" "github.com/joho/godotenv" ) // Engine ... type Engine struct { config *Config exiter exiter proxy *Proxy logger *logger watcher filenotify.FileWatcher debugMode bool runArgs []string running atomic.Bool eventCh chan string watcherStopCh chan bool // buildRunCh serves dual purpose: // 1. As a semaphore ensuring only one build runs at a time (buffer size 1) // 2. Carries each build's unique stop channel for cancellation // When a new build starts, it retrieves the previous build's stop channel, // closes it to signal cancellation, then inserts its own fresh channel. // This prevents the race condition where a new build could consume a stop // signal meant for a previous build (issue #784). buildRunCh chan chan struct{} // binStopCh is a channel for process termination control // Type chan<- chan int indicates it's a send-only channel that transmits another channel(chan int) binStopCh chan<- chan int exitCh chan bool mu sync.RWMutex watchers uint fileChecksums *checksumMap ll sync.Mutex // lock for logger // globalEnv stores the original env values before air modified them // key:original value (empty string means it was unset) globalEnv map[string]*string // loadedEnv tracks env values that were set by the last env file load loadedEnv map[string]string } // NewEngineWithConfig ... func NewEngineWithConfig(cfg *Config, debugMode bool) (*Engine, error) { logger := newLogger(cfg) watcher, err := newWatcher(cfg) if err != nil { return nil, err } var entryArgs []string if len(cfg.Build.FullBin) == 0 { entryArgs = cfg.Build.Entrypoint.args() } runArgs := make([]string, 0, len(entryArgs)+len(cfg.Build.ArgsBin)) if len(entryArgs) > 0 { runArgs = append(runArgs, entryArgs...) } runArgs = append(runArgs, cfg.Build.ArgsBin...) e := Engine{ config: cfg, exiter: defaultExiter{}, proxy: NewProxy(&cfg.Proxy), logger: logger, watcher: watcher, debugMode: debugMode, runArgs: runArgs, eventCh: make(chan string, 1000), watcherStopCh: make(chan bool, 10), buildRunCh: make(chan chan struct{}, 1), exitCh: make(chan bool), fileChecksums: &checksumMap{m: make(map[string]string)}, watchers: 0, globalEnv: map[string]*string{}, } return &e, nil } // NewEngine ... func NewEngine(cfgPath string, args map[string]TomlInfo, debugMode bool) (*Engine, error) { var err error cfg, err := InitConfig(cfgPath, args) if err != nil { return nil, err } return NewEngineWithConfig(cfg, debugMode) } // Run run run func (e *Engine) Run() { if len(os.Args) > 1 && os.Args[1] == "init" { configName, err := writeDefaultConfig() if err != nil { log.Fatalf("Failed writing default config: %+v", err) } fmt.Printf("%s file created to the current directory with the default settings\n", configName) return } e.mainDebug("CWD: %s", e.config.Root) var err error if err = e.checkRunEnv(); err != nil { os.Exit(1) } if err = e.watchConfiguredDirs(); err != nil { os.Exit(1) } e.start() e.cleanup() } func (e *Engine) checkRunEnv() error { p := e.config.tmpPath() if _, err := os.Stat(p); os.IsNotExist(err) { e.runnerLog("mkdir %s", p) if err := os.MkdirAll(p, 0o755); err != nil { e.runnerLog("failed to mkdir, error: %s", err.Error()) return err } } return nil } func (e *Engine) watchConfiguredDirs() error { type watchTarget struct { path string optional bool } targets := []watchTarget{{path: e.config.Root, optional: false}} for _, dir := range e.config.Build.extraIncludeDirs { targets = append(targets, watchTarget{path: dir, optional: true}) } seen := make(map[string]struct{}, len(targets)) for _, target := range targets { if target.path == "" { continue } cleaned := filepath.Clean(target.path) if _, ok := seen[cleaned]; ok { continue } if _, err := os.Stat(cleaned); err != nil { if os.IsNotExist(err) && target.optional { e.watcherLog("include_dir %s does not exist, skipping", e.config.rel(cleaned)) continue } return err } if err := e.watching(cleaned); err != nil { return err } seen[cleaned] = struct{}{} } return nil } func (e *Engine) watching(root string) error { return filepath.Walk(root, func(path string, info os.FileInfo, _ error) error { // NOTE: path is absolute if info != nil && !info.IsDir() { if e.checkIncludeFile(path) { return e.watchPath(path) } return nil } // exclude tmp dir if e.isTmpDir(path) { e.watcherLog("!exclude %s", e.config.rel(path)) return filepath.SkipDir } // exclude testdata dir if e.isTestDataDir(path) { e.watcherLog("!exclude %s", e.config.rel(path)) return filepath.SkipDir } // exclude hidden directories like .git, .idea, etc. if isHiddenDirectory(path) { return filepath.SkipDir } // exclude user specified directories if e.isExcludeDir(path) { e.watcherLog("!exclude %s", e.config.rel(path)) return filepath.SkipDir } isIn, walkDir := e.checkIncludeDir(path) if !walkDir { e.watcherLog("!exclude %s", e.config.rel(path)) return filepath.SkipDir } if isIn { return e.watchPath(path) } return nil }) } // cacheFileChecksums calculates and stores checksums for each non-excluded file it finds from root. func (e *Engine) cacheFileChecksums(root string) error { return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { if info == nil { return err } if info.IsDir() { return filepath.SkipDir } return err } if !info.Mode().IsRegular() { if e.isTmpDir(path) || e.isTestDataDir(path) || isHiddenDirectory(path) || e.isExcludeDir(path) { e.watcherDebug("!exclude checksum %s", e.config.rel(path)) return filepath.SkipDir } // Follow symbolic link if e.config.Build.FollowSymlink && (info.Mode()&os.ModeSymlink) > 0 { link, err := filepath.EvalSymlinks(path) if err != nil { return err } linkInfo, err := os.Stat(link) if err != nil { return err } if linkInfo.IsDir() { err = e.watchPath(link) if err != nil { return err } } return nil } } if e.isExcludeFile(path) || !e.isIncludeExt(path) && !e.checkIncludeFile(path) { e.watcherDebug("!exclude checksum %s", e.config.rel(path)) return nil } excludeRegex, err := e.isExcludeRegex(path) if err != nil { return err } if excludeRegex { e.watcherDebug("!exclude checksum %s", e.config.rel(path)) return nil } // update the checksum cache for the current file _ = e.isModified(path) return nil }) } func (e *Engine) watchPath(path string) error { if err := e.watcher.Add(path); err != nil { e.watcherLog("failed to watch %s, error: %s", path, err.Error()) return err } e.watcherLog("watching %s", e.config.rel(path)) go func() { e.withLock(func() { e.watchers++ }) defer func() { e.withLock(func() { e.watchers-- }) }() if e.config.Build.ExcludeUnchanged { err := e.cacheFileChecksums(path) if err != nil { e.watcherLog("error building checksum cache: %v", err) } } for { select { case <-e.watcherStopCh: return case ev := <-e.watcher.Events(): e.mainDebug("event: %+v", ev) if !validEvent(ev) { break } if isDir(ev.Name) { e.watchNewDir(ev.Name, removeEvent(ev)) break } if e.isExcludeFile(ev.Name) { break } excludeRegex, _ := e.isExcludeRegex(ev.Name) if excludeRegex { break } if !e.isIncludeExt(ev.Name) && !e.checkIncludeFile(ev.Name) { break } e.watcherDebug("%s has changed", e.config.rel(ev.Name)) e.eventCh <- ev.Name case err := <-e.watcher.Errors(): e.watcherLog("error: %s", err.Error()) } } }() return nil } func (e *Engine) watchNewDir(dir string, removeDir bool) { if e.isTmpDir(dir) { return } if e.isTestDataDir(dir) { return } if isHiddenDirectory(dir) || e.isExcludeDir(dir) { e.watcherLog("!exclude %s", e.config.rel(dir)) return } if removeDir { if err := e.watcher.Remove(dir); err != nil { e.watcherLog("failed to stop watching %s, error: %s", dir, err.Error()) } return } go func(dir string) { if err := e.watching(dir); err != nil { e.watcherLog("failed to watching %s, error: %s", dir, err.Error()) } }(dir) } func (e *Engine) isModified(filename string) bool { newChecksum, err := fileChecksum(filename) if err != nil { e.watcherDebug("can't determine if file was changed: %v - assuming it did without updating cache", err) return true } if e.fileChecksums.updateFileChecksum(filename, newChecksum) { e.watcherDebug("stored checksum for %s: %s", e.config.rel(filename), newChecksum) return true } return false } // Endless loop and never return func (e *Engine) start() { if e.config.Proxy.Enabled { go e.proxy.Run() e.mainLog("Proxy server listening on http://localhost%s", e.proxy.server.Addr) } e.running.Store(true) firstRunCh := make(chan bool, 1) firstRunCh <- true for { var filename string select { case <-e.exitCh: e.mainDebug("exit in start") return case filename = <-e.eventCh: if !e.isIncludeExt(filename) && !e.checkIncludeFile(filename) { continue } if e.config.Build.ExcludeUnchanged { if !e.isModified(filename) { e.mainLog("skipping %s because contents unchanged", e.config.rel(filename)) continue } } // cannot set buildDelay to 0, because when the write multiple events received in short time // it will start Multiple buildRuns: https://github.com/air-verse/air/issues/473 time.Sleep(e.config.buildDelay()) e.flushEvents() if e.config.Screen.ClearOnRebuild { if e.config.Screen.KeepScroll { // https://stackoverflow.com/questions/22891644/how-can-i-clear-the-terminal-screen-in-go fmt.Print("\033[2J") } else { // https://stackoverflow.com/questions/5367068/clear-a-terminal-screen-for-real/5367075#5367075 fmt.Print("\033c") } } e.mainLog("%s has changed", e.config.rel(filename)) case <-firstRunCh: // go down } // Stop any currently running build by closing its stop channel select { case oldStopCh := <-e.buildRunCh: // Close the old build's stop channel to signal it to stop close(oldStopCh) default: // No build is currently running } // if current app is running, stop it e.stopBin() go e.buildRun() } } func (e *Engine) loadEnvFile() { if len(e.config.EnvFiles) == 0 { return } // assume refreshed env is as big as the loaded env newEnv := make(map[string]string, len(e.loadedEnv)) for _, envPath := range e.config.EnvFiles { if !filepath.IsAbs(envPath) { envPath = filepath.Join(e.config.Root, envPath) } file, err := os.Open(envPath) if err != nil { if os.IsNotExist(err) { e.mainDebug("env file %q does not exist, skipping", envPath) } else { e.runnerLog("failed to open env file %q: %s", envPath, err.Error()) } continue } defer file.Close() fileEnv, err := godotenv.Parse(file) if err != nil { e.runnerLog("failed to parse env file %q: %s", envPath, err.Error()) return } for k, v := range fileEnv { if v, tracked := e.globalEnv[k]; !tracked { origVal, exists := os.LookupEnv(k) if exists { // not used yet, but might be useful for a future "override" feature e.globalEnv[k] = &origVal continue // untracked env values are likely global - don't override them } // on first encounter of a key, if no global value exists, mark as nil so // that on next load of .env file, globalEnv map value will not be overwritten e.globalEnv[k] = nil } else if tracked && v != nil { // only set values from file if not already present in the environment e.mainDebug("key %q already exists in the environment, skipping", k) continue } if err := os.Setenv(k, v); err != nil { e.runnerLog("failed to set env key %q: %s", k, err.Error()) } newEnv[k] = v } } // unset any keys that were removed from .env file, // but ignore those that were set before air was run for k := range e.loadedEnv { if _, exists := newEnv[k]; !exists { if orig := e.globalEnv[k]; orig == nil { if err := os.Unsetenv(k); err != nil { e.runnerLog("failed to restore env key %q: %s", k, err.Error()) } } } } e.loadedEnv = newEnv } func (e *Engine) buildRun() { // Create this build's unique stop channel myStopCh := make(chan struct{}) // Put our stop channel in buildRunCh (acts as semaphore + carries our stop token) e.buildRunCh <- myStopCh defer func() { <-e.buildRunCh }() // Check if we were already signaled to stop before we even started select { case <-myStopCh: return case <-e.exitCh: e.mainDebug("exit in buildRun before pre_cmd") return default: } e.loadEnvFile() var err error if err = e.runPreCmd(); err != nil { e.runnerLog("failed to execute pre_cmd: %s", err.Error()) if e.config.Build.StopOnError { return } } if output, err := e.building(); err != nil { e.buildLog("failed to build, error: %s", err.Error()) _ = e.writeBuildErrorLog(err.Error()) if e.config.Build.StopOnError { // It only makes sense to run it if we stop on error. Otherwise when // running the binary again the error modal will be overwritten by // the reload. if e.config.Proxy.Enabled { e.proxy.BuildFailed(BuildFailedMsg{ Error: err.Error(), Command: e.config.Build.Cmd, Output: output, }) } return } } // Check again before running the binary select { case <-myStopCh: return case <-e.exitCh: e.mainDebug("exit in buildRun after build") return default: } if err = e.runBin(); err != nil { e.runnerLog("failed to run, error: %s", err.Error()) } } func (e *Engine) flushEvents() { for { select { case <-e.eventCh: e.mainDebug("flushing events") default: return } } } // utility to execute commands, such as cmd & pre_cmd func (e *Engine) runCommand(command string) error { cmd, stdout, stderr, err := e.startCmd(command) if err != nil { return err } defer func() { stdout.Close() stderr.Close() }() copyOutput(os.Stdout, stdout) copyOutput(os.Stderr, stderr) // wait for command to finish return cmd.Wait() } func (e *Engine) runCommandCopyOutput(command string) (string, error) { // both stdout and stderr are piped to the same buffer, so ignore the second // one cmd, stdout, _, err := e.startCmd(command) if err != nil { return "", err } defer func() { stdout.Close() }() stdoutBytes, _ := io.ReadAll(stdout) _, _ = io.Copy(os.Stdout, strings.NewReader(string(stdoutBytes))) // wait for command to finish err = cmd.Wait() if err != nil { return string(stdoutBytes), err } return string(stdoutBytes), nil } // run cmd option in .air.toml func (e *Engine) building() (string, error) { e.buildLog("building...") output, err := e.runCommandCopyOutput(e.config.Build.Cmd) if err != nil { return output, err } return output, nil } // run pre_cmd option in .air.toml func (e *Engine) runPreCmd() error { for _, command := range e.config.Build.PreCmd { e.runnerLog("> %s", command) err := e.runCommand(command) if err != nil { return err } } return nil } // run post_cmd option in .air.toml func (e *Engine) runPostCmd() error { for _, command := range e.config.Build.PostCmd { e.runnerLog("> %s", command) err := e.runCommand(command) if err != nil { return err } } return nil } func (e *Engine) runBin() error { // killFunc returns a chan of chan of int that should be used to shutdown the bin currently being run // The chan int that is passed in will be used to signal completion of the shutdown killFunc := func(cmd *exec.Cmd, stdout io.ReadCloser, stderr io.ReadCloser, killCh chan<- struct{}, processExit <-chan struct{}) chan<- chan int { shutdown := make(chan chan int) var closer chan int go func() { defer func() { stdout.Close() stderr.Close() }() select { case closer = <-shutdown: // stopBin has been called from start or cleanup // defer the signalling of shutdown completion before attempting to kill further down defer close(closer) defer close(killCh) case <-processExit: // the process is exited, return e.withLock(func() { // Avoid deadlocking any racing shutdown request select { case c := <-shutdown: close(c) default: } e.binStopCh = nil }) return } e.mainDebug("trying to kill pid %d, cmd %+v", cmd.Process.Pid, cmd.Args) pid, err := e.killCmd(cmd) if err != nil { e.mainDebug("failed to kill PID %d, error: %s", pid, err.Error()) if cmd.ProcessState != nil && !cmd.ProcessState.Exited() { // Pass a non zero exit code to the closer to delegate the // decision wether to os.Exit or not closer <- 1 } } else { e.mainDebug("cmd killed, pid: %d", pid) } if e.config.Build.StopOnError { relBinPath := e.config.rel(e.config.binPath()) if relBinPath == "" || strings.HasPrefix(relBinPath, "..") { return } cmdBinPath := cmdPath(relBinPath) if _, err = os.Stat(cmdBinPath); os.IsNotExist(err) { return } if err = os.Remove(cmdBinPath); err != nil { e.mainLog("failed to remove %s, error: %s", relBinPath, err) } } }() return shutdown } e.runnerLog("running...") go func() { defer func() { select { case <-e.exitCh: e.mainDebug("exit in runBin") default: } }() // control killFunc should be kill or not killCh := make(chan struct{}) for { select { case <-killCh: return default: formattedBin := formatPath(e.config.runnerBin()) command := strings.Join(append([]string{formattedBin}, e.runArgs...), " ") cmd, stdout, stderr, err := e.startCmd(command) if err != nil { e.mainLog("failed to start %s, error: %s", e.config.rel(e.config.binPath()), err.Error()) close(killCh) continue } processExit := make(chan struct{}) e.mainDebug("running process pid %v", cmd.Process.Pid) if e.config.Proxy.Enabled { e.mainDebug("reloading proxy") e.proxy.Reload() } e.stopBin() e.withLock(func() { e.binStopCh = killFunc(cmd, stdout, stderr, killCh, processExit) }) go copyOutput(os.Stdout, stdout) go copyOutput(os.Stderr, stderr) state, _ := cmd.Process.Wait() close(processExit) switch state.ExitCode() { case 0: e.runnerLog("Process Exit with Code 0") case -1: // because when we use ctrl + c to stop will return -1 default: e.runnerLog("Process Exit with Code: %v", state.ExitCode()) } if !e.config.Build.Rerun { return } time.Sleep(e.config.rerunDelay()) } } }() return nil } func (e *Engine) stopBin() { e.mainDebug("initiating shutdown sequence") start := time.Now() e.mainDebug("shutdown completed in %v", time.Since(start)) exitCode := make(chan int) e.withLock(func() { if e.binStopCh != nil { e.mainDebug("sending shutdown command to killfunc") e.binStopCh <- exitCode e.binStopCh = nil } else { close(exitCode) } }) select { case ret := <-exitCode: if ret != 0 { e.exiter.Exit(ret) // Use exiter instead of direct os.Exit, it's for tests purpose. } case <-time.After(5 * time.Second): e.mainDebug("timed out waiting for process exit") } } func (e *Engine) cleanup() { e.mainLog("cleaning...") defer e.mainLog("see you again~") defer e.mainDebug("exited") if e.config.Proxy.Enabled { e.mainDebug("powering down the proxy...") if err := e.proxy.Stop(); err != nil { e.mainLog("failed to stop proxy: %+v", err) } } e.stopBin() e.mainDebug("waiting for close watchers..") e.withLock(func() { for i := 0; i < int(e.watchers); i++ { e.watcherStopCh <- true } }) e.mainDebug("waiting for buildRun...") var err error if err = e.watcher.Close(); err != nil { e.mainLog("failed to close watcher, error: %s", err.Error()) } e.mainDebug("waiting for clean ...") if e.config.Misc.CleanOnExit { e.mainLog("deleting %s", e.config.tmpPath()) if err = os.RemoveAll(e.config.tmpPath()); err != nil { e.mainLog("failed to delete tmp dir, err: %+v", err) } } e.running.Store(false) } // Stop the air func (e *Engine) Stop() { if err := e.runPostCmd(); err != nil { e.runnerLog("failed to execute post_cmd, error: %s", err.Error()) } close(e.exitCh) } ================================================ FILE: runner/engine_test.go ================================================ package runner import ( "errors" "fmt" "log" "net" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strings" "sync" "syscall" "testing" "time" "github.com/pelletier/go-toml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewEngine(t *testing.T) { _ = os.Unsetenv(airWd) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } if engine.logger == nil { t.Fatal("logger should not be nil") } if engine.config == nil { t.Fatal("Config should not be nil") } if engine.watcher == nil { t.Fatal("watcher should not be nil") } } func TestCheckRunEnv(t *testing.T) { _ = os.Unsetenv(airWd) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } nestedTmpDir := filepath.Join(t.TempDir(), "nested", "build") engine.config.TmpDir = nestedTmpDir err = engine.checkRunEnv() require.NoError(t, err) assert.DirExists(t, nestedTmpDir) } func TestWatching(t *testing.T) { engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } path, err := os.Getwd() if err != nil { t.Fatalf("Should not be fail: %s.", err) } path = strings.Replace(path, filepath.Join("_testdata", "toml"), "", 1) err = engine.watching(filepath.Join(path, "_testdata", "watching")) if err != nil { t.Fatalf("Should not be fail: %s.", err) } } func TestRegexes(t *testing.T) { engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } engine.config.Build.ExcludeRegex = []string{"foo\\.html$", "bar", "_test\\.go"} err = engine.config.preprocess(nil) if err != nil { t.Fatalf("Should not be fail: %s.", err) } result, err := engine.isExcludeRegex("./test/foo.html") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if result != true { t.Errorf("expected '%t' but got '%t'", true, result) } result, err = engine.isExcludeRegex("./test/bar/index.html") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if result != true { t.Errorf("expected '%t' but got '%t'", true, result) } result, err = engine.isExcludeRegex("./test/unrelated.html") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if result { t.Errorf("expected '%t' but got '%t'", false, result) } result, err = engine.isExcludeRegex("./myPackage/goFile_testxgo") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if result { t.Errorf("expected '%t' but got '%t'", false, result) } result, err = engine.isExcludeRegex("./myPackage/goFile_test.go") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if result != true { t.Errorf("expected '%t' but got '%t'", true, result) } } func TestRunCommand(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires touch") } // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } err = engine.runCommand("touch test.txt") if err != nil { t.Fatalf("Should not be fail: %s.", err) } if _, err := os.Stat("./test.txt"); err != nil { if os.IsNotExist(err) { t.Fatalf("Should not be fail: %s.", err) } } } func TestRunPreCmd(t *testing.T) { // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } if runtime.GOOS == "windows" { engine.config.Build.PreCmd = []string{`cmd.exe /c "echo hello air > pre_cmd.txt"`} } else { engine.config.Build.PreCmd = []string{"echo 'hello air' > pre_cmd.txt"} } err = engine.runPreCmd() if err != nil { t.Fatalf("Should not be fail: %s.", err) } if _, err := os.Stat("./pre_cmd.txt"); err != nil { if os.IsNotExist(err) { t.Fatalf("Should not be fail: %s.", err) } } } func TestRunPostCmd(t *testing.T) { // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } if runtime.GOOS == "windows" { engine.config.Build.PostCmd = []string{`cmd.exe /c "echo hello air > post_cmd.txt"`} } else { engine.config.Build.PostCmd = []string{"echo 'hello air' > post_cmd.txt"} } err = engine.runPostCmd() if err != nil { t.Fatalf("Should not be fail: %s.", err) } if _, err := os.Stat("./post_cmd.txt"); err != nil { if os.IsNotExist(err) { t.Fatalf("Should not be fail: %s.", err) } } } func TestRunBin(t *testing.T) { engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } err = engine.runBin() if err != nil { t.Fatalf("Should not be fail: %s.", err) } } func GetPort() (int, func()) { l, err := net.Listen("tcp", "127.0.0.1:0") port := l.Addr().(*net.TCPAddr).Port if err != nil { panic(err) } return port, func() { _ = l.Close() } } func TestRebuild(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unstable on Windows") } // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) engine.config.Build.ExcludeUnchanged = true if err != nil { t.Fatalf("Should not be fail: %s.", err) } wg := sync.WaitGroup{} wg.Add(1) go func() { engine.Run() t.Logf("engine stopped") wg.Done() }() err = waitingPortReady(t, port, time.Second*10) if err != nil { t.Fatalf("Should not be fail: %s.", err) } t.Logf("port is ready") // start rebuild t.Logf("start change main.go") // change file of main.go // just append a new empty line to main.go file, err := os.OpenFile("main.go", os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatalf("Should not be fail: %s.", err) } defer file.Close() _, err = file.WriteString("\n") if err != nil { t.Fatalf("Should not be fail: %s.", err) } err = waitingPortConnectionRefused(t, port, time.Second*10) if err != nil { t.Fatalf("timeout: %s.", err) } t.Logf("connection refused") err = waitingPortReady(t, port, time.Second*10) if err != nil { t.Fatalf("Should not be fail: %s.", err) } t.Logf("port is ready") // stop engine engine.Stop() t.Logf("engine stopped") // Wait for engine to fully stop err = waitForEngineState(t, engine, false, time.Second*3) if err != nil { t.Fatalf("engine did not stop: %s.", err) } wg.Wait() assert.True(t, checkPortConnectionRefused(port)) } func waitingPortConnectionRefused(t *testing.T, port int, timeout time.Duration) error { t.Helper() t.Logf("waiting port %d connection refused", port) // Use environment-aware timeout for CI compatibility timeoutMultiplier := 1.0 if os.Getenv("CI") != "" { timeoutMultiplier = 2.0 } adjustedTimeout := time.Duration(float64(timeout) * timeoutMultiplier) deadline := time.Now().Add(adjustedTimeout) ticker := time.NewTicker(20 * time.Millisecond) // Reduced from 100ms to 20ms defer ticker.Stop() for { _, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) if errors.Is(err, syscall.ECONNREFUSED) { return nil } if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for port %d connection refused (timeout: %v)", port, adjustedTimeout) } <-ticker.C } } func TestCtrlCWhenHaveKillDelay(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unstable on Windows") } // fix https://github.com/air-verse/air/issues/278 // generate a random port data := []byte("[build]\n kill_delay = \"2s\"") c := Config{} if err := toml.Unmarshal(data, &c); err != nil { t.Fatalf("Should not be fail: %s.", err) } port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } engine.config.Build.KillDelay = c.Build.KillDelay engine.config.Build.Delay = 2000 engine.config.Build.SendInterrupt = true if err := engine.config.preprocess(nil); err != nil { t.Fatalf("Should not be fail: %s.", err) } go func() { engine.Run() t.Logf("engine stopped") }() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) go func() { <-sigs engine.Stop() t.Logf("engine stopped") }() if err := waitingPortReady(t, port, time.Second*10); err != nil { t.Fatalf("Should not be fail: %s.", err) } sigs <- syscall.SIGINT err = waitingPortConnectionRefused(t, port, time.Second*10) if err != nil { t.Fatalf("Should not be fail: %s.", err) } // Wait for engine to fully stop - the test has kill_delay="2s" err = waitForEngineState(t, engine, false, time.Second*5) if err != nil { t.Logf("engine may not have stopped in time: %s", err) } assert.False(t, engine.running.Load()) } func TestCtrlCWhenREngineIsRunning(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unstable on Windows") } // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } go func() { engine.Run() t.Logf("engine stopped") }() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs engine.Stop() t.Logf("engine stopped") }() if err := waitingPortReady(t, port, time.Second*10); err != nil { t.Fatalf("Should not be fail: %s.", err) } sigs <- syscall.SIGINT time.Sleep(time.Second * 1) err = waitingPortConnectionRefused(t, port, time.Second*10) if err != nil { t.Fatalf("Should not be fail: %s.", err) } assert.False(t, engine.running.Load()) } func TestCtrlCWithFailedBin(t *testing.T) { timeout := 5 * time.Second done := make(chan struct{}) go func() { dir := initWithQuickExitGoCode(t) chdir(t, dir) engine, err := NewEngine("", nil, true) assert.NoError(t, err) engine.config.Build.Bin = "" sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) var wg sync.WaitGroup wg.Add(1) go func() { engine.Run() t.Logf("engine stopped") wg.Done() }() go func() { <-sigs engine.Stop() t.Logf("engine stopped") }() time.Sleep(time.Second * 1) sigs <- syscall.SIGINT wg.Wait() close(done) }() select { case <-done: case <-time.After(timeout): t.Error("Test timed out") } } func TestFixCloseOfChannelAfterCtrlC(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unstable on Windows") } // fix https://github.com/air-verse/air/issues/294 dir := initWithBuildFailedCode(t) chdir(t, dir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } // Silence engine logs to keep this test output readable. engine.config.Log.Silent = true silenceBuildCmd(engine.config) sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigs) go func() { engine.Run() t.Logf("engine stopped") }() go func() { <-sigs engine.Stop() t.Logf("engine stopped") }() buildLogPath := engine.config.buildLogPath() if err := waitForCondition(t, time.Second*5, func() bool { info, err := os.Stat(buildLogPath) if err != nil { return false } return info.Size() > 0 }, "first build failure log"); err != nil { t.Fatalf("build did not fail as expected: %s", err) } port, f := GetPort() f() // correct code err = generateGoCode(dir, port) if err != nil { t.Fatalf("Should not be fail: %s.", err) } if err := waitingPortReady(t, port, time.Second*10); err != nil { t.Fatalf("Should not be fail: %s.", err) } // ctrl + c sigs <- syscall.SIGINT if err := waitingPortConnectionRefused(t, port, time.Second*10); err != nil { t.Fatalf("Should not be fail: %s.", err) } if err := waitForEngineState(t, engine, false, time.Second*5); err != nil { t.Fatalf("engine did not stop: %s", err) } assert.False(t, engine.running.Load()) } func TestFixCloseOfChannelAfterTwoFailedBuild(t *testing.T) { // fix https://github.com/air-verse/air/issues/294 // happens after two failed builds dir := initWithBuildFailedCode(t) // change dir to tmpDir chdir(t, dir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } engine.config.Log.Silent = true silenceBuildCmd(engine.config) sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { engine.Run() t.Logf("engine stopped") }() go func() { <-sigs engine.Stop() t.Logf("engine stopped") }() // Wait for first build to complete (with error) - reduced from 3s to 1s // Since the build fails immediately, 1s is sufficient time.Sleep(time.Millisecond * 500) // edit *.go file to create build error again file, err := os.OpenFile("main.go", os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { t.Fatalf("Should not be fail: %s.", err) } defer file.Close() _, err = file.WriteString("\n") if err != nil { t.Fatalf("Should not be fail: %s.", err) } // Wait for second build attempt - reduced from 3s to 500ms time.Sleep(time.Millisecond * 500) // ctrl + c sigs <- syscall.SIGINT // Wait for engine to stop err = waitForEngineState(t, engine, false, time.Second*3) if err != nil { t.Logf("engine may not have stopped cleanly: %s", err) } assert.False(t, engine.running.Load()) } // waitingPortReady waits until the port is ready to be used. func waitingPortReady(t *testing.T, port int, timeout time.Duration) error { t.Helper() t.Logf("waiting port %d ready", port) // Use environment-aware timeout for CI compatibility timeoutMultiplier := 1.0 if os.Getenv("CI") != "" { timeoutMultiplier = 2.0 } adjustedTimeout := time.Duration(float64(timeout) * timeoutMultiplier) deadline := time.Now().Add(adjustedTimeout) ticker := time.NewTicker(20 * time.Millisecond) // Reduced from 100ms to 20ms defer ticker.Stop() for { conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) if err == nil { _ = conn.Close() return nil } if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for port %d ready (timeout: %v)", port, adjustedTimeout) } <-ticker.C } } func TestRun(t *testing.T) { // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } go func() { engine.Run() }() // Wait for port to be ready instead of fixed sleep err = waitingPortReady(t, port, time.Second*10) if err != nil { t.Fatalf("Should not be fail: %s.", err) } assert.True(t, checkPortHaveBeenUsed(port)) t.Logf("try to stop") engine.Stop() // Wait for engine to stop instead of fixed sleep err = waitForEngineState(t, engine, false, time.Second*3) if err != nil { t.Fatalf("engine did not stop: %s.", err) } assert.False(t, checkPortHaveBeenUsed(port)) t.Logf("stopped") } func checkPortConnectionRefused(port int) bool { conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) defer func() { if conn != nil { _ = conn.Close() } }() return errors.Is(err, syscall.ECONNREFUSED) } func checkPortHaveBeenUsed(port int) bool { conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port)) if err != nil { return false } _ = conn.Close() return true } func initTestEnv(t *testing.T, port int) string { tempDir := t.TempDir() t.Setenv(airWd, tempDir) t.Logf("tempDir: %s", tempDir) // generate golang code to tempdir err := generateGoCode(tempDir, port) if err != nil { t.Fatalf("Should not be fail: %s.", err) } return tempDir } func initWithBuildFailedCode(t *testing.T) string { tempDir := t.TempDir() t.Setenv(airWd, tempDir) t.Logf("tempDir: %s", tempDir) // generate golang code to tempdir err := generateBuildErrorGoCode(tempDir) if err != nil { t.Fatalf("Should not be fail: %s.", err) } return tempDir } func initWithQuickExitGoCode(t *testing.T) string { tempDir := t.TempDir() t.Setenv(airWd, tempDir) t.Logf("tempDir: %s", tempDir) // generate golang code to tempdir err := generateQuickExitGoCode(tempDir) if err != nil { t.Fatalf("Should not be fail: %s.", err) } return tempDir } func generateQuickExitGoCode(dir string) error { code := `package main // You can edit this code! // Click here and start typing. import "fmt" func main() { fmt.Println("Hello, 世界") } ` file, err := os.Create(dir + "/main.go") if err != nil { return err } _, err = file.WriteString(code) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } // generate go mod file mod := `module air.sample.com go 1.17 ` file, err = os.Create(dir + "/go.mod") if err != nil { return err } _, err = file.WriteString(mod) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } return nil } func generateBuildErrorGoCode(dir string) error { code := `package main // You can edit this code! // Click here and start typing. func main() { Println("Hello, 世界") } ` file, err := os.Create(dir + "/main.go") if err != nil { return err } _, err = file.WriteString(code) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } // generate go mod file mod := `module air.sample.com go 1.17 ` file, err = os.Create(dir + "/go.mod") if err != nil { return err } _, err = file.WriteString(mod) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } return nil } // generateGoCode generates golang code to tempdir func generateGoCode(dir string, port int) error { code := fmt.Sprintf(`package main import ( "log" "net/http" ) func main() { log.Fatal(http.ListenAndServe("127.0.0.1:%v", nil)) } `, port) file, err := os.Create(dir + "/main.go") if err != nil { return err } _, err = file.WriteString(code) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } // generate go mod file mod := `module air.sample.com go 1.17 ` file, err = os.Create(dir + "/go.mod") if err != nil { return err } _, err = file.WriteString(mod) if err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } return nil } func silenceBuildCmd(cfg *Config) { if cfg == nil { return } if runtime.GOOS == "windows" { cfg.Build.Cmd = fmt.Sprintf("%s > $null 2>&1", cfg.Build.Cmd) return } cfg.Build.Cmd = fmt.Sprintf("%s >/dev/null 2>&1", cfg.Build.Cmd) } func TestRebuildWhenRunCmdUsingDLV(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires touch") } if _, err := exec.LookPath("dlv"); err != nil { t.Skip("dlv not available in PATH") } // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } engine.config.Build.Cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ." engine.config.Build.Bin = "" dlvPort, f := GetPort() f() engine.config.Build.FullBin = fmt.Sprintf("dlv exec --accept-multiclient --log --headless --continue --listen :%d --api-version 2 ./tmp/main", dlvPort) _ = engine.config.preprocess(nil) go func() { engine.Run() }() if err := waitingPortReady(t, port, time.Second*40); err != nil { t.Fatalf("Should not be fail: %s.", err) } t.Logf("start change main.go") // change file of main.go // just append a new empty line to main.go go func() { file, err := os.OpenFile("main.go", os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { log.Fatalf("Should not be fail: %s.", err) } defer file.Close() _, err = file.WriteString("\n") if err != nil { log.Fatalf("Should not be fail: %s.", err) } }() err = waitingPortConnectionRefused(t, port, time.Second*10) if err != nil { t.Fatalf("timeout: %s.", err) } t.Logf("connection refused") err = waitingPortReady(t, port, time.Second*40) if err != nil { t.Fatalf("Should not be fail: %s.", err) } t.Logf("port is ready") // stop engine engine.Stop() // Wait for engine to stop err = waitForEngineState(t, engine, false, time.Second*5) if err != nil { t.Fatalf("engine did not stop: %s.", err) } t.Logf("engine stopped") assert.True(t, checkPortConnectionRefused(port)) } func TestWriteDefaultConfig(t *testing.T) { port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) configName, err := writeDefaultConfig() if err != nil { t.Fatal(err) } // check the file exists if _, err := os.Stat(configName); err != nil { t.Fatal(err) } raw, err := os.ReadFile(configName) if err != nil { t.Fatal(err) } expectedPrefix := schemaHeader + "\n\n" assert.True(t, strings.HasPrefix(string(raw), expectedPrefix), "config should start with schema header") // check the file content is right actual, err := readConfig(configName) if err != nil { t.Fatal(err) } expect := defaultConfig() if len(expect.Build.Entrypoint) == 0 && expect.Build.Bin != "" { expect.Build.Entrypoint = entrypoint{expect.Build.Bin} } assert.Equal(t, expect, *actual) } func TestCheckNilSliceShouldBeenOverwrite(t *testing.T) { port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) // write easy config file config := ` [build] cmd = "go build ." bin = "tmp/main" exclude_regex = [] exclude_dir = ["test"] exclude_file = ["main.go"] include_file = ["test/not_a_test.go"] ` if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil { t.Fatal(err) } engine, err := NewEngine(".air.toml", nil, true) if err != nil { t.Fatal(err) } assert.Equal(t, []string{"go", "tpl", "tmpl", "html"}, engine.config.Build.IncludeExt) assert.Equal(t, []string{}, engine.config.Build.ExcludeRegex) assert.Equal(t, []string{"test"}, engine.config.Build.ExcludeDir) // add new config assert.Equal(t, []string{"main.go"}, engine.config.Build.ExcludeFile) assert.Equal(t, []string{"test/not_a_test.go"}, engine.config.Build.IncludeFile) assert.Equal(t, "go build .", engine.config.Build.Cmd) } func TestShouldIncludeGoTestFile(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires sed") } port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) _, err := writeDefaultConfig() if err != nil { t.Fatal(err) } // write go test file file, err := os.Create("main_test.go") if err != nil { t.Fatal(err) } _, err = file.WriteString(`package main import "testing" func Test(t *testing.T) { t.Log("testing") } `) if err != nil { t.Fatal(err) } // run sed // check the file exists if _, err := os.Stat(dftTOML); err != nil { t.Fatal(err) } // check is MacOS var cmd *exec.Cmd toolName := "sed" if runtime.GOOS == "darwin" { toolName = "gsed" } cmd = exec.Command(toolName, "-i", "s/\"_test.*go\"//g", ".air.toml") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { t.Skipf("unable to run %s, make sure the tool is installed to run this test", toolName) } time.Sleep(time.Second * 2) engine, err := NewEngine(".air.toml", nil, false) if err != nil { t.Fatal(err) } go func() { engine.Run() }() t.Logf("start change main_test.go") // change file of main_test.go // just append a new empty line to main_test.go if err = waitingPortReady(t, port, time.Second*40); err != nil { t.Fatal(err) } go func() { file, err = os.OpenFile("main_test.go", os.O_APPEND|os.O_WRONLY, 0o644) assert.NoError(t, err) defer file.Close() _, err = file.WriteString("\n") assert.NoError(t, err) }() // should Have rebuild if err = waitingPortReady(t, port, time.Second*10); err != nil { t.Fatal(err) } } func TestCreateNewDir(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires touch") } // generate a random port port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) // change dir to tmpDir chdir(t, tmpDir) engine, err := NewEngine("", nil, true) if err != nil { t.Fatalf("Should not be fail: %s.", err) } go func() { engine.Run() }() if err := waitingPortReady(t, port, 5*time.Second); err != nil { t.Fatalf("Should not be fail: %s.", err) } // create a new dir make dir if err = os.Mkdir(tmpDir+"/dir", 0o644); err != nil { t.Fatal(err) } // no need reload if err = waitingPortConnectionRefused(t, port, 3*time.Second); err == nil { t.Fatal("should raise a error") } engine.Stop() time.Sleep(2 * time.Second) } func TestShouldIncludeIncludedFile(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires sh") } port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) chdir(t, tmpDir) config := ` [build] cmd = "true" # do nothing full_bin = "sh main.sh" include_ext = ["sh"] include_dir = ["nonexist"] # prevent default "." watch from taking effect include_file = ["main.sh"] ` if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil { t.Fatal(err) } err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf original > output"), 0o755) if err != nil { t.Fatal(err) } engine, err := NewEngine(dftTOML, nil, false) if err != nil { t.Fatal(err) } go func() { engine.Run() }() time.Sleep(time.Second * 1) bytes, err := os.ReadFile("output") if err != nil { t.Fatal(err) } assert.Equal(t, []byte("original"), bytes) t.Logf("start change main.sh") go func() { err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf modified > output"), 0o755) if err != nil { log.Fatalf("Error updating file: %s.", err) } }() time.Sleep(time.Second * 3) bytes, err = os.ReadFile("output") if err != nil { t.Fatal(err) } assert.Equal(t, []byte("modified"), bytes) } func TestShouldIncludeIncludedFileWithoutIncludedExt(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires sh") } port, f := GetPort() f() t.Logf("port: %d", port) tmpDir := initTestEnv(t, port) chdir(t, tmpDir) config := ` [build] cmd = "true" # do nothing full_bin = "sh main.sh" include_ext = ["go"] include_dir = ["nonexist"] # prevent default "." watch from taking effect include_file = ["main.sh"] ` if err := os.WriteFile(dftTOML, []byte(config), 0o644); err != nil { t.Fatal(err) } err := os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf original > output"), 0o755) if err != nil { t.Fatal(err) } engine, err := NewEngine(dftTOML, nil, false) if err != nil { t.Fatal(err) } go func() { engine.Run() }() time.Sleep(time.Second * 1) bytes, err := os.ReadFile("output") if err != nil { t.Fatal(err) } assert.Equal(t, []byte("original"), bytes) t.Logf("start change main.sh") go func() { err = os.WriteFile("main.sh", []byte("#!/bin/sh\nprintf modified > output"), 0o755) if err != nil { log.Fatalf("Error updating file: %s.", err) } }() time.Sleep(time.Second * 3) bytes, err = os.ReadFile("output") if err != nil { t.Fatal(err) } assert.Equal(t, []byte("modified"), bytes) } type testExiter struct { t *testing.T called bool expectCode int } func (te *testExiter) Exit(code int) { te.called = true if code != te.expectCode { te.t.Fatalf("expected exit code %d, got %d", te.expectCode, code) } } func TestEngineExit(t *testing.T) { tests := []struct { name string setup func(*Engine, chan<- int) expectCode int wantCalled bool }{ { name: "normal exit - no error", setup: func(_ *Engine, exitCode chan<- int) { go func() { exitCode <- 0 }() }, expectCode: 0, wantCalled: false, }, { name: "error exit - non-zero code", setup: func(_ *Engine, exitCode chan<- int) { go func() { exitCode <- 1 }() }, expectCode: 1, wantCalled: true, }, { name: "process timeout", setup: func(_ *Engine, _ chan<- int) { }, expectCode: 0, wantCalled: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e, err := NewEngine("", nil, true) if err != nil { t.Fatal(err) } exiter := &testExiter{ t: t, expectCode: tt.expectCode, } e.exiter = exiter exitCode := make(chan int) if tt.setup != nil { tt.setup(e, exitCode) } select { case ret := <-exitCode: if ret != 0 { e.exiter.Exit(ret) } case <-time.After(1 * time.Millisecond): // timeout case } if tt.wantCalled != exiter.called { t.Errorf("Exit() called = %v, want %v", exiter.called, tt.wantCalled) } }) } } // TestBuildRunRaceCondition tests that a new build does not receive // stop signals meant for a previous build. This is a regression test for issue #784. // // The fix uses a channel-of-channels pattern where each build gets its own unique // stop channel. When a new build is triggered, it retrieves the previous build's // stop channel and closes it to signal cancellation. func TestBuildRunRaceCondition(t *testing.T) { e, err := NewEngine("", nil, true) if err != nil { t.Fatal(err) } e.config.Log.Silent = true // Simulate the race condition scenario from issue #784: // 1. Build A starts and puts its stop channel in buildRunCh // 2. Build B is triggered, retrieves Build A's channel and closes it // 3. Build B puts its own fresh channel in buildRunCh // 4. Build B should NOT be affected by Build A's closed channel // Simulate Build A putting its stop channel in buildRunCh buildAStopCh := make(chan struct{}) e.buildRunCh <- buildAStopCh // Simulate Build B being triggered (mimics what start() does) var retrievedChannel chan struct{} select { case retrievedChannel = <-e.buildRunCh: close(retrievedChannel) // Signal Build A to stop default: t.Fatal("Expected Build A's stop channel to be in buildRunCh") } // Verify we got Build A's channel if retrievedChannel != buildAStopCh { t.Error("Should have retrieved Build A's stop channel") } // Verify Build A's channel is closed select { case <-buildAStopCh: // Good - Build A was signaled to stop default: t.Error("Build A's stop channel should have been closed") } // Now simulate Build B starting with its own channel buildBStopCh := make(chan struct{}) e.buildRunCh <- buildBStopCh // Build B should NOT be affected by Build A's closed channel select { case <-buildBStopCh: t.Error("Build B's stop channel should NOT be closed yet") case <-time.After(50 * time.Millisecond): // Good - Build B is still running } // Test that closing Build B's channel does signal Build B to stop close(buildBStopCh) select { case <-buildBStopCh: // Good - Build B received the stop signal case <-time.After(50 * time.Millisecond): t.Error("Build B should have been stopped when its channel was closed") } // Clean up - remove Build B's channel from buildRunCh select { case <-e.buildRunCh: // Successfully cleaned up default: t.Error("Expected Build B's channel to still be in buildRunCh") } } // TestBuildRunRaceConditionRapidChanges tests rapid file changes don't cause deadlock func TestBuildRunRaceConditionRapidChanges(t *testing.T) { e, err := NewEngine("", nil, true) if err != nil { t.Fatal(err) } e.config.Log.Silent = true // Simulate 5 rapid builds in succession channels := make([]chan struct{}, 5) for i := 0; i < 5; i++ { // If there's a previous build, stop it select { case oldCh := <-e.buildRunCh: close(oldCh) default: } // Start new build channels[i] = make(chan struct{}) e.buildRunCh <- channels[i] } // All previous builds should be signaled to stop for i := 0; i < 4; i++ { select { case <-channels[i]: // Good - was signaled to stop default: t.Errorf("Build %d should have been signaled to stop", i) } } // Last build should NOT be stopped select { case <-channels[4]: t.Error("Last build should still be running") default: // Good } // Clean up <-e.buildRunCh } func TestEngineLoadEnvFile(t *testing.T) { tmpDir := t.TempDir() envPath := filepath.Join(tmpDir, ".env") originalValue := "original_global_value" t.Setenv("TEST_GLOBAL_VAR", originalValue) const initialEnv = `TEST_VAR1=value1 TEST_VAR2=value2 TEST_GLOBAL_VAR=overridden_value ` err := os.WriteFile(envPath, []byte(initialEnv), 0o644) require.NoError(t, err) cfg := defaultConfig() cfg.Root = tmpDir cfg.EnvFiles = []string{".env"} engine, err := NewEngineWithConfig(&cfg, false) require.NoError(t, err) engine.loadEnvFile() assert.Equal(t, "value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be set") assert.Equal(t, "value2", os.Getenv("TEST_VAR2"), "TEST_VAR2 should be set") assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden") // remove TEST_VAR2 const updatedEnv = `TEST_VAR1=updated_value1 TEST_GLOBAL_VAR=still_overridden ` err = os.WriteFile(envPath, []byte(updatedEnv), 0o644) require.NoError(t, err) engine.loadEnvFile() assert.Equal(t, "updated_value1", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be updated") // since TEST_VAR2 only exists in environment thanks to air, it should get unset on removal _, exists := os.LookupEnv("TEST_VAR2") assert.False(t, exists, "TEST_VAR2 should be unset after removal from .env") assert.Equal(t, "original_global_value", os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should NOT be overridden") const finalEnv = `TEST_VAR1=final_value` err = os.WriteFile(envPath, []byte(finalEnv), 0o644) require.NoError(t, err) engine.loadEnvFile() assert.Equal(t, "final_value", os.Getenv("TEST_VAR1"), "TEST_VAR1 should be final") assert.Equal(t, originalValue, os.Getenv("TEST_GLOBAL_VAR"), "TEST_GLOBAL_VAR should be restored to original value") } ================================================ FILE: runner/exiter.go ================================================ package runner import "os" type exiter interface { Exit(code int) } type defaultExiter struct{} func (d defaultExiter) Exit(code int) { os.Exit(code) } ================================================ FILE: runner/flag.go ================================================ package runner import ( "flag" ) // ParseConfigFlag parse toml information for flag func ParseConfigFlag(f *flag.FlagSet) map[string]TomlInfo { c := defaultConfig() m := flatConfig(c) for k, v := range m { f.StringVar(v.Value, k, v.fieldValue, v.usage) } return m } ================================================ FILE: runner/flag_test.go ================================================ package runner import ( "flag" "log" "path/filepath" "runtime" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestFlag(t *testing.T) { t.Parallel() // table driven tests type testCase struct { name string args []string expected string key string } testCases := []testCase{ { name: "test1", args: []string{"--build.cmd", "go build -o ./tmp/main ."}, expected: "go build -o ./tmp/main .", key: "build.cmd", }, { name: "tmp dir test", args: []string{"--tmp_dir", "test"}, expected: "test", key: "tmp_dir", }, { name: "check bool", args: []string{"--build.exclude_unchanged", "true"}, expected: "true", key: "build.exclude_unchanged", }, { name: "check int", args: []string{"--build.kill_delay", "1000"}, expected: "1000", key: "build.kill_delay", }, { name: "check exclude_regex", args: []string{"--build.exclude_regex", `["_test.go"]`}, expected: `["_test.go"]`, key: "build.exclude_regex", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() flag := flag.NewFlagSet(t.Name(), flag.ExitOnError) cmdArgs := ParseConfigFlag(flag) require.NoError(t, flag.Parse(tc.args)) assert.Equal(t, tc.expected, *cmdArgs[tc.key].Value) }) } } func TestConfigRuntimeArgs(t *testing.T) { // table driven tests type testCase struct { name string args []string key string check func(t *testing.T, conf *Config) } testCases := []testCase{ { name: "test1", args: []string{"--build.cmd", "go build -o ./tmp/main ."}, key: "build.cmd", check: func(t *testing.T, conf *Config) { assert.Equal(t, "go build -o ./tmp/main .", conf.Build.Cmd) }, }, { name: "tmp dir test", args: []string{"--tmp_dir", "test"}, key: "tmp_dir", check: func(t *testing.T, conf *Config) { assert.Equal(t, "test", conf.TmpDir) }, }, { name: "check int64", args: []string{"--build.kill_delay", "1000"}, key: "build.kill_delay", check: func(t *testing.T, conf *Config) { assert.Equal(t, time.Duration(1000), conf.Build.KillDelay) }, }, { name: "check bool", args: []string{"--build.exclude_unchanged", "true"}, key: "build.exclude_unchanged", check: func(t *testing.T, conf *Config) { assert.True(t, conf.Build.ExcludeUnchanged) }, }, { name: "check exclude_regex", args: []string{"--build.exclude_regex", "_test.go,.html"}, check: func(t *testing.T, conf *Config) { assert.Equal(t, []string{"_test.go", ".html"}, conf.Build.ExcludeRegex) }, }, { name: "check exclude_regex with empty string", args: []string{"--build.exclude_regex", ""}, check: func(t *testing.T, conf *Config) { assert.Equal(t, []string{}, conf.Build.ExcludeRegex) t.Logf("%+v", conf.Build.ExcludeDir) assert.NotEqual(t, []string{}, conf.Build.ExcludeDir) }, }, { name: "check full_bin", args: []string{"--build.full_bin", "APP_ENV=dev APP_USER=air ./tmp/main"}, check: func(t *testing.T, conf *Config) { want := "APP_ENV=dev APP_USER=air ./tmp/main" if runtime.GOOS == "windows" { want += ".exe" } assert.Equal(t, want, conf.Build.Bin) }, }, { name: "check exclude_regex patterns compiled", args: []string{"--build.exclude_regex", "test_pattern\\.go"}, check: func(t *testing.T, conf *Config) { assert.Equal(t, []string{"test_pattern\\.go"}, conf.Build.ExcludeRegex) patterns, err := conf.Build.RegexCompiled() require.NoError(t, err) require.NotNil(t, patterns) require.Len(t, patterns, 1) assert.True(t, patterns[0].MatchString("test_pattern.go"), "regex should match test_pattern.go") assert.False(t, patterns[0].MatchString("other_file.go"), "regex shouldn't match other_file.go") }, }, { name: "check entrypoint flag", args: []string{"--build.entrypoint", "./tmp/server"}, check: func(t *testing.T, conf *Config) { want := filepath.Join("tmp", "server") assert.True(t, strings.HasSuffix(conf.Build.Entrypoint.binary(), want), "entrypoint %s does not end with %s", conf.Build.Entrypoint.binary(), want) }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() chdir(t, dir) flag := flag.NewFlagSet(t.Name(), flag.ExitOnError) cmdArgs := ParseConfigFlag(flag) _ = flag.Parse(tc.args) cfg, err := InitConfig("", cmdArgs) if err != nil { log.Fatal(err) return } tc.check(t, cfg) }) } } ================================================ FILE: runner/logger.go ================================================ package runner import ( "fmt" "os" "strings" "time" "github.com/fatih/color" ) var ( rawColor = "raw" // TODO: support more colors colorMap = map[string]color.Attribute{ "red": color.FgRed, "green": color.FgGreen, "yellow": color.FgYellow, "blue": color.FgBlue, "magenta": color.FgMagenta, "cyan": color.FgCyan, "white": color.FgWhite, } ) type logFunc func(string, ...interface{}) type logger struct { config *Config colors map[string]string loggers map[string]logFunc } func newLogger(cfg *Config) *logger { if cfg == nil { return nil } colors := cfg.colorInfo() loggers := make(map[string]logFunc, len(colors)) for name, nameColor := range colors { loggers[name] = newLogFunc(nameColor, cfg.Log) } loggers["default"] = defaultLogger() return &logger{ config: cfg, colors: colors, loggers: loggers, } } func newLogFunc(colorname string, cfg cfgLog) logFunc { return func(msg string, v ...interface{}) { // There are some escape sequences to format color in terminal, so cannot // just trim new line from right. if cfg.Silent { return } msg = strings.ReplaceAll(msg, "\n", "") msg = strings.TrimSpace(msg) if len(msg) == 0 { return } // TODO: filter msg by regex msg = msg + "\n" if cfg.AddTime { t := time.Now().Format("15:04:05") msg = fmt.Sprintf("[%s] %s", t, msg) } if colorname == rawColor { fmt.Fprintf(os.Stdout, msg, v...) } else { color.New(getColor(colorname)).Fprintf(color.Output, msg, v...) } } } func getColor(name string) color.Attribute { if v, ok := colorMap[name]; ok { return v } return color.FgWhite } func (l *logger) main() logFunc { return l.getLogger("main") } func (l *logger) build() logFunc { return l.getLogger("build") } func (l *logger) runner() logFunc { return l.getLogger("runner") } func (l *logger) watcher() logFunc { return l.getLogger("watcher") } func rawLogger() logFunc { return newLogFunc("raw", defaultConfig().Log) } func defaultLogger() logFunc { return newLogFunc("white", defaultConfig().Log) } func (l *logger) getLogger(name string) logFunc { v, ok := l.loggers[name] if !ok { return rawLogger() } return v } ================================================ FILE: runner/proxy.go ================================================ package runner import ( "bytes" "compress/gzip" "context" _ "embed" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "github.com/andybalholm/brotli" ) var ( //go:embed proxy.js ProxyScript string //go:embed worker.js WorkerScript string ) type Streamer interface { AddSubscriber() *Subscriber RemoveSubscriber(id int32) Reload() BuildFailed(msg BuildFailedMsg) Stop() } // contentEncoding represents the type of content encoding used in HTTP responses. type contentEncoding int const ( encodingNone contentEncoding = iota encodingGzip encodingBrotli ) type Proxy struct { server *http.Server client *http.Client config *cfgProxy stream Streamer } func NewProxy(cfg *cfgProxy) *Proxy { p := &Proxy{ config: cfg, server: &http.Server{ Addr: fmt.Sprintf(":%d", cfg.ProxyPort), }, client: &http.Client{ CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, stream: NewProxyStream(), } return p } func (p *Proxy) Run() { http.HandleFunc("/", p.proxyHandler) http.HandleFunc("/__air_internal/sse", p.reloadHandler) http.HandleFunc("GET /__air_internal/worker.js", p.workerScriptHandler) if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(p.Stop()) } } func (p *Proxy) Reload() { p.stream.Reload() } func (p *Proxy) BuildFailed(msg BuildFailedMsg) { p.stream.BuildFailed(msg) } func (p *Proxy) injectLiveReload(resp *http.Response) (string, bool, error) { var reader io.Reader = resp.Body decoded := false switch detectContentEncoding(resp.Header) { case encodingGzip: gzipReader, err := gzip.NewReader(resp.Body) if err != nil { return "", false, fmt.Errorf("proxy inject: failed to init gzip reader: %w", err) } defer gzipReader.Close() reader = gzipReader decoded = true case encodingBrotli: reader = brotli.NewReader(resp.Body) decoded = true } buf := new(bytes.Buffer) if _, err := buf.ReadFrom(reader); err != nil { return "", decoded, fmt.Errorf("proxy inject: failed to read body from http response: %w", err) } page := buf.String() // the script will be injected before the end of the body tag. In case the tag is missing, the injection will be skipped with no error. body := strings.LastIndex(page, "") if body == -1 { return page, decoded, nil } script := "" return page[:body] + script + page[body:], decoded, nil } func (p *Proxy) proxyHandler(w http.ResponseWriter, r *http.Request) { appURL := r.URL appURL.Scheme = "http" appURL.Host = fmt.Sprintf("localhost:%d", p.config.AppPort) if err := r.ParseForm(); err != nil { http.Error(w, "proxy handler: bad form", http.StatusInternalServerError) return } var body io.Reader if len(r.Form) > 0 { body = strings.NewReader(r.Form.Encode()) } else { body = r.Body } req, err := http.NewRequest(r.Method, appURL.String(), body) if err != nil { http.Error(w, "proxy handler: unable to create request", http.StatusInternalServerError) return } // Copy the headers from the original request for name, values := range r.Header { for _, value := range values { req.Header.Add(name, value) } } req.Header.Set("X-Forwarded-For", r.RemoteAddr) // set the via header viaHeaderValue := fmt.Sprintf("%s %s", r.Proto, r.Host) req.Header.Set("Via", viaHeaderValue) // air will restart the server. it may take a few seconds for it to start back up. // therefore, we retry until the server becomes available or this retry loop exits with an error. timeout := time.Duration(p.config.AppStartTimeout) * time.Millisecond if timeout == 0 { timeout = defaultProxyAppStartTimeout * time.Millisecond } ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() var resp *http.Response resp, err = p.client.Do(req.WithContext(ctx)) for err != nil { // Check if timeout has been exceeded if ctx.Err() != nil { err = ctx.Err() break } time.Sleep(100 * time.Millisecond) resp, err = p.client.Do(req.WithContext(ctx)) } if err != nil { http.Error(w, "proxy handler: unable to reach app (try increasing the proxy.app_start_timeout)", http.StatusInternalServerError) return } defer resp.Body.Close() // Copy the headers from the proxy response except Content-Length for k, vv := range resp.Header { for _, v := range vv { if k == "Content-Length" { continue } w.Header().Add(k, v) } } w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Add("Via", viaHeaderValue) // Determine if this is a streaming response streaming := isStreamingResponse(resp) // Handle non-HTML responses if !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { // Check flusher support BEFORE writing headers for streaming responses var flusher http.Flusher if streaming { var ok bool flusher, ok = w.(http.Flusher) if !ok { http.Error(w, "proxy handler: streaming not supported", http.StatusInternalServerError) return } } // Set Content-Length only for non-streaming responses if !streaming { if cl := resp.Header.Get("Content-Length"); cl != "" { w.Header().Set("Content-Length", cl) } } w.WriteHeader(resp.StatusCode) if streaming { // Use streaming copy with immediate flushing _ = streamCopy(w, resp.Body, flusher) return } // Use standard copy for non-streaming responses if _, err := io.Copy(w, resp.Body); err != nil { http.Error(w, "proxy handler: failed to forward the response body", http.StatusInternalServerError) return } } else { // HTML: inject live reload script page, decoded, err := p.injectLiveReload(resp) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if decoded { w.Header().Del("Content-Encoding") } w.Header().Set("Content-Length", strconv.Itoa((len([]byte(page))))) w.WriteHeader(resp.StatusCode) if _, err := io.WriteString(w, page); err != nil { http.Error(w, "proxy handler: unable to inject live reload script", http.StatusInternalServerError) return } } } // detectContentEncoding determines the content encoding type from HTTP headers. // Returns encodingNone for unsupported or multiple encodings (e.g., "gzip, br"). func detectContentEncoding(header http.Header) contentEncoding { encoding := header.Get("Content-Encoding") if encoding == "" { return encodingNone } // Only support single encoding; multiple encodings (e.g., "gzip, br") are rare // and complex to handle, so we skip injection in those cases. trimmed := strings.TrimSpace(strings.ToLower(encoding)) switch trimmed { case "gzip", "x-gzip": return encodingGzip case "br": return encodingBrotli default: return encodingNone } } func (p *Proxy) reloadHandler(w http.ResponseWriter, r *http.Request) { flusher, err := w.(http.Flusher) if !err { http.Error(w, "reload handler: streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") sub := p.stream.AddSubscriber() go func() { <-r.Context().Done() p.stream.RemoveSubscriber(sub.id) }() w.WriteHeader(http.StatusOK) flusher.Flush() for msg := range sub.msgCh { fmt.Fprint(w, msg.AsSSE()) flusher.Flush() } } func (p *Proxy) workerScriptHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/javascript") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, WorkerScript) } func (p *Proxy) Stop() error { p.stream.Stop() return p.server.Close() } // isStreamingResponse determines if the response should be streamed immediately // without buffering. This applies to: // 1. Server-Sent Events (SSE): Content-Type contains "text/event-stream" // 2. Chunked transfer encoding: Transfer-Encoding is "chunked" func isStreamingResponse(resp *http.Response) bool { // Check for SSE contentType := resp.Header.Get("Content-Type") if strings.Contains(contentType, "text/event-stream") { return true } // Check for chunked encoding transferEncoding := resp.Header.Get("Transfer-Encoding") return transferEncoding == "chunked" } // streamCopy copies data from src to dst, flushing after each read. // This ensures real-time delivery for streaming responses like SSE. // Uses a 512-byte buffer to balance between latency and performance. func streamCopy(dst io.Writer, src io.Reader, flusher http.Flusher) error { // Use 512-byte buffer for better responsiveness buf := make([]byte, 512) for { nr, readErr := src.Read(buf) if nr > 0 { nw, writeErr := dst.Write(buf[:nr]) if writeErr != nil { return writeErr } if nr != nw { return io.ErrShortWrite } // Flush immediately after each write flusher.Flush() } if readErr != nil { if readErr == io.EOF { return nil } return readErr } } } ================================================ FILE: runner/proxy.js ================================================ (() => { let worker = null; const disconnectWorker = () => { if (worker) { worker.port.postMessage('disconnect'); } }; // Try to use SharedWorker for shared SSE connection across all windows if (window.SharedWorker) { try { worker = new SharedWorker('/__air_internal/worker.js', { name: 'air-sse-worker' }); worker.port.onmessage = (event) => { const message = event.data; switch (message.type) { case 'reload': location.reload(); break; case 'build-failed': const data = parseBuildFailed(message.data); showErrorInModal(data); break; } }; worker.port.start(); // Gracefully disconnect from SharedWorker when the window is closed window.addEventListener('beforeunload', disconnectWorker); window.addEventListener('pagehide', disconnectWorker); } catch (e) { // Setting up SharedWorker failed, so fall back to per-window EventSource console.warn('air: SharedWorker setup failed, falling back to EventSource', e); worker = null; } } // SharedWorker is not available or failed somehow. Use per-window EventSource as fallback if (!worker) { const eventSource = new EventSource("/__air_internal/sse"); window.addEventListener('beforeunload', function () { eventSource.close(); }); window.addEventListener('pagehide', function () { eventSource.close(); }); eventSource.addEventListener('reload', () => { location.reload(); }); eventSource.addEventListener('build-failed', (event) => { const data = parseBuildFailed(event.data); showErrorInModal(data); }); } function parseBuildFailed(raw) { try { const parsed = JSON.parse(raw); return { error: parsed.error ?? "Build failed", command: parsed.command ?? "", output: parsed.output ?? "", }; } catch (e) { console.warn("air: failed to parse build-failed payload", e); return { error: "Build failed", command: "", output: String(raw), }; } } function showErrorInModal(data) { document.body.insertAdjacentHTML(`beforeend`, `
Build Error
`); const modal = document.getElementById('air__modal'); const modalBody = document.getElementById('air__modal-body'); const modalClose = document.getElementById('air__modal-close'); modalBody.innerHTML = ` Build Cmd:
${data.command}

Output:
${data.output}

Error:
${data.error}
`; modal.style.display = 'flex'; modalClose.addEventListener('click', () => { modal.style.display = 'none'; }); } })(); ================================================ FILE: runner/proxy_stream.go ================================================ package runner import ( "encoding/json" "fmt" "sync" "sync/atomic" ) type ProxyStream struct { mu sync.Mutex subscribers map[int32]*Subscriber count atomic.Int32 } type StreamMessageType string const ( StreamMessageReload StreamMessageType = "reload" StreamMessageBuildFailed StreamMessageType = "build-failed" ) type StreamMessage struct { Type StreamMessageType Data interface{} } type BuildFailedMsg struct { Error string `json:"error"` Command string `json:"command"` Output string `json:"output"` } type Subscriber struct { id int32 msgCh chan StreamMessage } func NewProxyStream() *ProxyStream { return &ProxyStream{subscribers: make(map[int32]*Subscriber)} } func (stream *ProxyStream) Stop() { for id := range stream.subscribers { stream.RemoveSubscriber(id) } stream.count = atomic.Int32{} } func (stream *ProxyStream) AddSubscriber() *Subscriber { stream.mu.Lock() defer stream.mu.Unlock() stream.count.Add(1) sub := &Subscriber{id: stream.count.Load(), msgCh: make(chan StreamMessage)} stream.subscribers[stream.count.Load()] = sub return sub } func (stream *ProxyStream) RemoveSubscriber(id int32) { stream.mu.Lock() defer stream.mu.Unlock() if _, ok := stream.subscribers[id]; ok { close(stream.subscribers[id].msgCh) delete(stream.subscribers, id) } } func (stream *ProxyStream) Reload() { for _, sub := range stream.subscribers { sub.msgCh <- StreamMessage{ Type: StreamMessageReload, Data: nil, } } } func (stream *ProxyStream) BuildFailed(err BuildFailedMsg) { for _, sub := range stream.subscribers { sub.msgCh <- StreamMessage{ Type: StreamMessageBuildFailed, Data: err, } } } func (m StreamMessage) AsSSE() string { s := "event: " + string(m.Type) + "\n" s += "data: " + stringify(m.Data) + "\n" return s + "\n" } func stringify(v any) string { b, err := json.Marshal(v) if err != nil { return fmt.Sprintf("{\"error\":\"Failed to marshal message: %s\"}", err) } return string(b) } ================================================ FILE: runner/proxy_stream_test.go ================================================ package runner import ( "sync" "sync/atomic" "testing" "github.com/stretchr/testify/assert" ) func find(s map[int32]*Subscriber, id int32) bool { for _, sub := range s { if sub.id == id { return true } } return false } func TestProxyStream(t *testing.T) { stream := NewProxyStream() var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(_ int) { defer wg.Done() _ = stream.AddSubscriber() }(i) } wg.Wait() if got, exp := len(stream.subscribers), 10; got != exp { t.Errorf("expect subscribers count to be %d, got %d", exp, got) } doneCh := make(chan struct{}) go func() { stream.Reload() doneCh <- struct{}{} }() var reloadCount atomic.Int32 for _, sub := range stream.subscribers { wg.Add(1) go func(sub *Subscriber) { defer wg.Done() <-sub.msgCh reloadCount.Add(1) }(sub) } wg.Wait() <-doneCh if got, exp := reloadCount.Load(), int32(10); got != exp { t.Errorf("expect reloadCount %d, got %d", exp, got) } stream.RemoveSubscriber(2) if find(stream.subscribers, 2) { t.Errorf("expected subscriber 2 not to be found") } stream.AddSubscriber() if !find(stream.subscribers, 11) { t.Errorf("expected subscriber 11 to be found") } stream.Stop() if got, exp := len(stream.subscribers), 0; got != exp { t.Errorf("expected subscribers count to be %d, got %d", exp, got) } } func TestBuildFailureMessage(t *testing.T) { stream := NewProxyStream() sub := stream.AddSubscriber() msg := BuildFailedMsg{ Error: "build failed", Command: "go build", Output: "error output", } go stream.BuildFailed(msg) received := <-sub.msgCh assert.Equal(t, StreamMessageBuildFailed, received.Type) assert.Equal(t, msg, received.Data) } ================================================ FILE: runner/proxy_test.go ================================================ package runner import ( "bytes" "compress/gzip" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "sync" "testing" "time" "github.com/andybalholm/brotli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type reloader struct { subCh chan struct{} reloadCh chan StreamMessage } func (r *reloader) AddSubscriber() *Subscriber { r.subCh <- struct{}{} return &Subscriber{msgCh: r.reloadCh} } func (r *reloader) RemoveSubscriber(_ int32) { close(r.subCh) } func (r *reloader) Reload() {} func (r *reloader) BuildFailed(BuildFailedMsg) {} func (r *reloader) Stop() {} var proxyPort = 8090 func getServerPort(t *testing.T, srv *httptest.Server) int { mockURL, err := url.Parse(srv.URL) if err != nil { t.Fatal(err) } port, err := strconv.Atoi(mockURL.Port()) if err != nil { t.Fatal(err) } return port } func TestProxy_run(t *testing.T) { _ = os.Unsetenv(airWd) cfg := &cfgProxy{ Enabled: true, ProxyPort: 1111, AppPort: 2222, } proxy := NewProxy(cfg) if proxy.config == nil { t.Fatal("config should not be nil") } if proxy.server.Addr == "" { t.Fatal("server address should not be nil") } go func() { proxy.Run() }() if err := proxy.Stop(); err != nil { t.Errorf("failed stopping the proxy: %v", err) } } func TestProxy_proxyHandler(t *testing.T) { tests := []struct { name string req func() *http.Request assert func(*http.Request) }{ { name: "get_request_with_headers", req: func() *http.Request { req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d", proxyPort), nil) req.Header.Set("foo", "bar") return req }, assert: func(resp *http.Request) { assert.Equal(t, "bar", resp.Header.Get("foo")) }, }, { name: "post_form_request", req: func() *http.Request { formData := url.Values{} formData.Add("foo", "bar") req := httptest.NewRequest("POST", fmt.Sprintf("http://localhost:%d", proxyPort), strings.NewReader(formData.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req }, assert: func(resp *http.Request) { require.NoError(t, resp.ParseForm()) assert.Equal(t, "bar", resp.Form.Get("foo")) }, }, { name: "get_request_with_query_string", req: func() *http.Request { return httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d?q=%s", proxyPort, "air"), nil) }, assert: func(resp *http.Request) { q := resp.URL.Query() assert.Equal(t, "q=air", q.Encode()) }, }, { name: "put_json_request", req: func() *http.Request { body := []byte(`{"foo": "bar"}`) req := httptest.NewRequest("PUT", fmt.Sprintf("http://localhost:%d/a/b/c", proxyPort), bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json; charset=UTF-8") return req }, assert: func(resp *http.Request) { type Response struct { Foo string `json:"foo"` } var r Response require.NoError(t, json.NewDecoder(resp.Body).Decode(&r)) assert.Equal(t, "/a/b/c", resp.URL.Path) assert.Equal(t, "bar", r.Foo) }, }, { name: "set_via_header", req: func() *http.Request { req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d", proxyPort), nil) return req }, assert: func(resp *http.Request) { assert.Equal(t, fmt.Sprintf("HTTP/1.1 localhost:%d", proxyPort), resp.Header.Get("Via")) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { tt.assert(r) })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) proxy.proxyHandler(httptest.NewRecorder(), tt.req()) }) } } func TestProxy_injectLiveReload(t *testing.T) { tests := []struct { name string given *http.Response expect string }{ { name: "when_no_body_should_not_be_injected", given: &http.Response{ Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, StatusCode: http.StatusOK, Body: http.NoBody, }, expect: "", }, { name: "when_missing_body_should_not_be_injected", given: &http.Response{ Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"text/html"}, }, Body: io.NopCloser(strings.NewReader(`

test

`)), }, expect: "

test

", }, { name: "when_text_html_and_body_is_present_should_be_injected", given: &http.Response{ Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"text/html"}, }, Body: io.NopCloser(strings.NewReader(`

test

`)), }, expect: fmt.Sprintf(`

test

`, ProxyScript), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: 1111, AppPort: 2222, }) got, _, _ := proxy.injectLiveReload(tt.given) if got != tt.expect { // Use a more descriptive error message if len(got) > 100 || len(tt.expect) > 100 { t.Errorf("Script injection mismatch.\nGot length: %d\nExpected length: %d", len(got), len(tt.expect)) } else { t.Errorf("expected page %+v, got %v", tt.expect, got) } } }) } } func TestProxy_reloadHandler(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "thin air") })) srvPort := getServerPort(t, srv) defer srv.Close() reloader := &reloader{subCh: make(chan struct{}), reloadCh: make(chan StreamMessage)} cfg := &cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, } proxy := &Proxy{ config: cfg, server: &http.Server{ Addr: fmt.Sprintf("localhost:%d", proxyPort), }, stream: reloader, } req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/internal/reload", proxyPort), nil) rec := httptest.NewRecorder() var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() proxy.reloadHandler(rec, req) }() <-reloader.subCh reloader.reloadCh <- StreamMessage{ Type: StreamMessageReload, Data: nil, } close(reloader.reloadCh) wg.Wait() if !rec.Flushed { t.Errorf("request should have been flushed") } resp := rec.Result() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { t.Errorf("reading body: %v", err) } expected := "event: reload\ndata: null\n\n" if got := string(bodyBytes); got != expected { t.Errorf("expected %q but got %q", expected, got) } expectedHeaders := map[string]string{ "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", } for key, value := range expectedHeaders { if got := resp.Header.Get(key); got != value { t.Errorf("expected header %s to be %q but got %q", key, value, got) } } } func TestProxy_proxyHandler_GzipHTML(t *testing.T) { body := "

gzip

" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Encoding", "gzip") gzipWriter := gzip.NewWriter(w) if _, err := io.WriteString(gzipWriter, body); err != nil { t.Errorf("write gzip body: %v", err) } if err := gzipWriter.Close(); err != nil { t.Errorf("close gzip writer: %v", err) } })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/", proxyPort), nil) req.Header.Set("Accept-Encoding", "gzip") rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Empty(t, resp.Header.Get("Content-Encoding")) responseBody, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Contains(t, string(responseBody), ProxyScript) } func TestProxy_proxyHandler_BrotliHTML(t *testing.T) { body := "

brotli

" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Encoding", "br") brotliWriter := brotli.NewWriter(w) if _, err := io.WriteString(brotliWriter, body); err != nil { t.Errorf("write brotli body: %v", err) } if err := brotliWriter.Close(); err != nil { t.Errorf("close brotli writer: %v", err) } })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/", proxyPort), nil) req.Header.Set("Accept-Encoding", "br") rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Empty(t, resp.Header.Get("Content-Encoding")) responseBody, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Contains(t, string(responseBody), ProxyScript) } func TestDetectContentEncoding(t *testing.T) { tests := []struct { name string encoding string expected contentEncoding }{ {name: "empty", encoding: "", expected: encodingNone}, {name: "gzip", encoding: "gzip", expected: encodingGzip}, {name: "x-gzip", encoding: "x-gzip", expected: encodingGzip}, {name: "gzip_uppercase", encoding: "GZIP", expected: encodingGzip}, {name: "brotli", encoding: "br", expected: encodingBrotli}, {name: "brotli_uppercase", encoding: "BR", expected: encodingBrotli}, {name: "deflate_unsupported", encoding: "deflate", expected: encodingNone}, {name: "multiple_encodings", encoding: "gzip, br", expected: encodingNone}, {name: "unknown", encoding: "unknown", expected: encodingNone}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { header := http.Header{} if tt.encoding != "" { header.Set("Content-Encoding", tt.encoding) } result := detectContentEncoding(header) assert.Equal(t, tt.expected, result) }) } } func TestProxy_proxyHandler_SSE(t *testing.T) { events := []string{ "event: message\ndata: {\"id\":1}\n\n", "event: message\ndata: {\"id\":2}\n\n", } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { t.Fatal("expected flusher") } w.WriteHeader(http.StatusOK) for _, event := range events { fmt.Fprint(w, event) flusher.Flush() } })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/events", proxyPort), nil) rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) body, err := io.ReadAll(resp.Body) require.NoError(t, err) expected := strings.Join(events, "") assert.Equal(t, expected, string(body)) } func TestIsStreamingResponse(t *testing.T) { tests := []struct { name string headers http.Header expected bool }{ { name: "SSE content type", headers: http.Header{ "Content-Type": []string{"text/event-stream"}, }, expected: true, }, { name: "SSE with charset", headers: http.Header{ "Content-Type": []string{"text/event-stream; charset=utf-8"}, }, expected: true, }, { name: "chunked encoding", headers: http.Header{ "Transfer-Encoding": []string{"chunked"}, }, expected: true, }, { name: "both SSE and chunked", headers: http.Header{ "Content-Type": []string{"text/event-stream"}, "Transfer-Encoding": []string{"chunked"}, }, expected: true, }, { name: "regular JSON", headers: http.Header{ "Content-Type": []string{"application/json"}, }, expected: false, }, { name: "regular HTML", headers: http.Header{ "Content-Type": []string{"text/html"}, }, expected: false, }, { name: "no headers", headers: http.Header{}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := &http.Response{ Header: tt.headers, } result := isStreamingResponse(resp) assert.Equal(t, tt.expected, result) }) } } // mockFlusher is a simple mock implementation of http.Flusher for testing type mockFlusher struct { flushed bool } func (m *mockFlusher) Flush() { m.flushed = true } func TestStreamCopy(t *testing.T) { t.Run("copies data and flushes", func(t *testing.T) { data := []byte("test data for streaming") src := bytes.NewReader(data) dst := &bytes.Buffer{} flusher := &mockFlusher{} err := streamCopy(dst, src, flusher) require.NoError(t, err) assert.Equal(t, data, dst.Bytes()) assert.True(t, flusher.flushed, "should have called Flush") }) t.Run("handles empty source", func(t *testing.T) { src := bytes.NewReader([]byte{}) dst := &bytes.Buffer{} flusher := &mockFlusher{} err := streamCopy(dst, src, flusher) require.NoError(t, err) assert.Empty(t, dst.Bytes()) }) t.Run("handles large data", func(t *testing.T) { // Create data larger than buffer size (512 bytes) data := bytes.Repeat([]byte("x"), 2048) src := bytes.NewReader(data) dst := &bytes.Buffer{} flusher := &mockFlusher{} err := streamCopy(dst, src, flusher) require.NoError(t, err) assert.Equal(t, data, dst.Bytes()) assert.True(t, flusher.flushed) }) } func TestProxy_proxyHandler_Chunked(t *testing.T) { chunks := []string{"chunk1", "chunk2", "chunk3"} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Transfer-Encoding", "chunked") w.Header().Set("Content-Type", "application/octet-stream") flusher, ok := w.(http.Flusher) assert.True(t, ok, "expected flusher") w.WriteHeader(http.StatusOK) for _, chunk := range chunks { fmt.Fprint(w, chunk) flusher.Flush() time.Sleep(10 * time.Millisecond) } })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/stream", proxyPort), nil) rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) // Note: httptest.ResponseRecorder may not preserve Transfer-Encoding header // but the actual HTTP response will work correctly assert.Empty(t, resp.Header.Get("Content-Length"), "streaming response should not have Content-Length") body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, "chunk1chunk2chunk3", string(body)) } func TestProxy_proxyHandler_NonStreaming(t *testing.T) { content := "regular response content" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Length", strconv.Itoa(len(content))) w.WriteHeader(http.StatusOK) fmt.Fprint(w, content) })) defer srv.Close() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/api", proxyPort), nil) rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) assert.Equal(t, strconv.Itoa(len(content)), resp.Header.Get("Content-Length")) body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, content, string(body)) } func TestProxy_appStartTimeout(t *testing.T) { tests := []struct { name string appStartDelay int // milliseconds - simulated app startup delay configTimeout int // milliseconds - configured timeout (0 = use default) expectSuccess bool expectInBody string expectNotInBody string }{ { name: "fast_startup_succeeds", appStartDelay: 50, configTimeout: 1000, expectSuccess: true, expectInBody: "OK", }, { name: "slow_startup_within_timeout_succeeds", appStartDelay: 500, configTimeout: 1000, expectSuccess: true, expectInBody: "OK", }, { name: "slow_startup_exceeds_timeout_fails", appStartDelay: 1500, configTimeout: 500, expectSuccess: false, expectInBody: "unable to reach app", expectNotInBody: "OK", }, { name: "uses_default_timeout_when_zero", appStartDelay: 500, configTimeout: 0, // Should use defaultProxyAppStartTimeout (5000ms) expectSuccess: true, expectInBody: "OK", }, { name: "custom_timeout_respected", appStartDelay: 800, configTimeout: 1000, expectSuccess: true, expectInBody: "OK", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Track whether server has "started" var serverReady sync.WaitGroup serverReady.Add(1) // Create a test server that delays before responding srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Wait for simulated startup delay on first request serverReady.Wait() w.WriteHeader(http.StatusOK) fmt.Fprint(w, "OK") })) defer srv.Close() // Simulate app startup delay in background go func() { if tt.appStartDelay > 0 { // Sleep to simulate app initialization time time.Sleep(time.Duration(tt.appStartDelay) * time.Millisecond) } serverReady.Done() }() srvPort := getServerPort(t, srv) proxy := NewProxy(&cfgProxy{ Enabled: true, ProxyPort: proxyPort, AppPort: srvPort, AppStartTimeout: tt.configTimeout, }) req := httptest.NewRequest("GET", fmt.Sprintf("http://localhost:%d/", proxyPort), nil) rec := httptest.NewRecorder() proxy.proxyHandler(rec, req) resp := rec.Result() bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) body := string(bodyBytes) if tt.expectSuccess { assert.Equal(t, http.StatusOK, resp.StatusCode, "expected successful response") assert.Contains(t, body, tt.expectInBody, "response body should contain expected content") } else { assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected error response") assert.Contains(t, body, tt.expectInBody, "error message should contain expected text") if tt.expectNotInBody != "" { assert.NotContains(t, body, tt.expectNotInBody, "response should not contain unexpected content") } } }) } } ================================================ FILE: runner/test_util.go ================================================ // Package runner … package runner import ( "fmt" "os" "testing" "time" ) func chdir(t *testing.T, targetDir string) { originalDir, err := os.Getwd() if err != nil { t.Fatalf("failed to getwd: %v", err) } if err := os.Chdir(targetDir); err != nil { t.Fatalf("failed to change working directory: %v", err) } t.Cleanup(func() { if err := os.Chdir(originalDir); err != nil { t.Fatalf("failed to restore working directory: %v", err) } }) } // waitForCondition waits for a condition to be true with fast polling. // Uses environment-aware timeout multiplier for CI compatibility. func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool, description string) error { t.Helper() // CI environments may be slower, use 2x timeout timeoutMultiplier := 1.0 if os.Getenv("CI") != "" { timeoutMultiplier = 2.0 } adjustedTimeout := time.Duration(float64(timeout) * timeoutMultiplier) deadline := time.Now().Add(adjustedTimeout) ticker := time.NewTicker(20 * time.Millisecond) // Fast polling: 20ms defer ticker.Stop() for { if condition() { return nil } if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for: %s (timeout: %v)", description, adjustedTimeout) } <-ticker.C } } // waitForEngineState waits for engine to reach the specified running state. func waitForEngineState(t *testing.T, engine *Engine, running bool, timeout time.Duration) error { t.Helper() return waitForCondition(t, timeout, func() bool { return engine.running.Load() == running }, fmt.Sprintf("engine running=%v", running)) } ================================================ FILE: runner/util.go ================================================ package runner import ( "bufio" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log" "os" "path/filepath" "reflect" "runtime" "strconv" "strings" "sync" "github.com/fsnotify/fsnotify" ) const ( sliceCmdArgSeparator = "," // extWildcard is used in include_ext to match all file extensions extWildcard = "*" ) func (e *Engine) mainLog(format string, v ...interface{}) { if e.config.Log.Silent { return } e.logWithLock(func() { e.logger.main()(format, v...) }) } func (e *Engine) mainDebug(format string, v ...interface{}) { if e.config.Log.Silent { return } if e.debugMode { e.mainLog(format, v...) } } func (e *Engine) buildLog(format string, v ...interface{}) { if e.config.Log.Silent { return } if e.debugMode || !e.config.Log.MainOnly { e.logWithLock(func() { e.logger.build()(format, v...) }) } } func (e *Engine) runnerLog(format string, v ...interface{}) { if e.config.Log.Silent { return } if e.debugMode || !e.config.Log.MainOnly { e.logWithLock(func() { e.logger.runner()(format, v...) }) } } func (e *Engine) watcherLog(format string, v ...interface{}) { if e.config.Log.Silent { return } if e.debugMode || !e.config.Log.MainOnly { e.logWithLock(func() { e.logger.watcher()(format, v...) }) } } func (e *Engine) watcherDebug(format string, v ...interface{}) { if e.config.Log.Silent { return } if e.debugMode { e.watcherLog(format, v...) } } func (e *Engine) isTmpDir(path string) bool { return path == e.config.tmpPath() } func (e *Engine) isTestDataDir(path string) bool { return path == e.config.testDataPath() } func isHiddenDirectory(path string) bool { return len(path) > 1 && strings.HasPrefix(filepath.Base(path), ".") && filepath.Base(path) != ".." } func cleanPath(path string) string { return strings.TrimSuffix(strings.TrimSpace(path), "/") } func isSubPath(base, target string) bool { if base == "" || target == "" { return false } base = filepath.Clean(base) target = filepath.Clean(target) rel, err := filepath.Rel(base, target) if err != nil { return false } if rel == "." { return true } rel = filepath.Clean(rel) prefix := ".." + string(os.PathSeparator) return rel != ".." && !strings.HasPrefix(rel, prefix) } func (e *Engine) isExcludeDir(path string) bool { cleanName := cleanPath(e.config.rel(path)) for _, d := range e.config.Build.ExcludeDir { if cleanName == d { return true } } return false } // return isIncludeDir, walkDir func (e *Engine) checkIncludeDir(path string) (bool, bool) { path = filepath.Clean(path) if len(e.config.Build.includeDirAbs) == 0 { return true, true } if !isSubPath(e.config.Root, path) { return true, true } walkDir := false for _, dir := range e.config.Build.includeDirAbs { dir = filepath.Clean(dir) if isSubPath(dir, path) { return true, true } if isSubPath(path, dir) { walkDir = true } } return false, walkDir } func (e *Engine) checkIncludeFile(path string) bool { cleanName := cleanPath(e.config.rel(path)) iFile := e.config.Build.IncludeFile if len(iFile) == 0 { // ignore empty return false } if cleanName == "." { return false } for _, d := range iFile { if d == cleanName { return true } } return false } func (e *Engine) isIncludeExt(path string) bool { ext := filepath.Ext(path) for _, v := range e.config.Build.IncludeExt { if strings.TrimSpace(v) == extWildcard { // Wildcard matches all files, but exclude the binary file return !e.isBinPath(path) } if ext == "."+strings.TrimSpace(v) { return true } } return false } // isBinPath checks if the given path is the binary file path func (e *Engine) isBinPath(path string) bool { binPath := e.config.binPath() if binPath == "" { return false } // Normalize the path for comparison absPath, err := filepath.Abs(path) if err != nil { return false } absBinPath, err := filepath.Abs(binPath) if err != nil { return false } return absPath == absBinPath } func (e *Engine) isExcludeRegex(path string) (bool, error) { regexes, err := e.config.Build.RegexCompiled() if err != nil { return false, err } for _, re := range regexes { if re.Match([]byte(path)) { return true, nil } } return false, nil } func (e *Engine) isExcludeFile(path string) bool { cleanName := cleanPath(e.config.rel(path)) for _, d := range e.config.Build.ExcludeFile { matched, err := filepath.Match(d, cleanName) if err == nil && matched { return true } } return false } func (e *Engine) writeBuildErrorLog(msg string) error { var err error f, err := os.OpenFile(e.config.buildLogPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } if _, err = f.Write([]byte(msg)); err != nil { return err } return f.Close() } func (e *Engine) withLock(f func()) { e.mu.Lock() f() e.mu.Unlock() } func (e *Engine) logWithLock(f func()) { e.ll.Lock() f() e.ll.Unlock() } func copyOutput(dst io.Writer, src io.Reader) { scanner := bufio.NewScanner(src) for scanner.Scan() { _, _ = dst.Write([]byte(scanner.Text() + "\n")) } } func expandPath(path string) (string, error) { if strings.HasPrefix(path, "~/") { home := os.Getenv("HOME") return home + path[1:], nil } var err error wd, err := os.Getwd() if err != nil { return "", err } if path == "." { return wd, nil } if strings.HasPrefix(path, "./") { return wd + path[1:], nil } return path, nil } func isDir(path string) bool { i, err := os.Stat(path) if err != nil { return false } return i.IsDir() } func validEvent(ev fsnotify.Event) bool { return ev.Op&fsnotify.Create == fsnotify.Create || ev.Op&fsnotify.Write == fsnotify.Write || ev.Op&fsnotify.Remove == fsnotify.Remove } func removeEvent(ev fsnotify.Event) bool { return ev.Op&fsnotify.Remove == fsnotify.Remove } func cmdPath(path string) string { return strings.Split(path, " ")[0] } func adaptToVariousPlatforms(c *Config) { // Fix the default configuration is not used in Windows // Use the unix configuration on Windows if runtime.GOOS == PlatformWindows { extName := ".exe" originBin := c.Build.Bin if 0 < len(c.Build.FullBin) { if !strings.HasSuffix(c.Build.FullBin, extName) { c.Build.FullBin += extName } } // bin=/tmp/main cmd=go build -o ./tmp/main.exe main.go if !strings.Contains(c.Build.Cmd, c.Build.Bin) && strings.Contains(c.Build.Cmd, originBin) { c.Build.Cmd = strings.Replace(c.Build.Cmd, originBin, c.Build.Bin, 1) } } } // fileChecksum returns a checksum for the given file's contents. func fileChecksum(filename string) (checksum string, err error) { contents, err := os.ReadFile(filename) if err != nil { return "", err } // If the file is empty, an editor might've been in the process of rewriting the file when we read it. // This can happen often if editors are configured to run format after save. // Instead of calculating a new checksum, we'll assume the file was unchanged, but return an error to force a rebuild anyway. if len(contents) == 0 { return "", errors.New("empty file, forcing rebuild without updating checksum") } h := sha256.New() if _, err := h.Write(contents); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } // checksumMap is a thread-safe map to store file checksums. type checksumMap struct { l sync.Mutex m map[string]string } // updateFileChecksum updates the filename with the given checksum if different. func (a *checksumMap) updateFileChecksum(filename, newChecksum string) (ok bool) { a.l.Lock() defer a.l.Unlock() oldChecksum, ok := a.m[filename] if !ok || oldChecksum != newChecksum { a.m[filename] = newChecksum return true } return false } // TomlInfo is a struct for toml config file type TomlInfo struct { fieldPath string field reflect.StructField Value *string fieldValue string usage string } func setValue2Struct(v reflect.Value, fieldName string, value string) { index := strings.Index(fieldName, ".") if index == -1 && len(fieldName) == 0 { return } fields := strings.Split(fieldName, ".") var addressableVal reflect.Value switch v.Type().String() { case "*runner.Config": addressableVal = v.Elem() default: addressableVal = v } if len(fields) == 1 { // string slice int switch case field := addressableVal.FieldByName(fieldName) switch field.Kind() { case reflect.String: field.SetString(value) case reflect.Slice: var parts []string if len(value) == 0 { parts = []string{} } else { parts = strings.Split(value, sliceCmdArgSeparator) } slice := reflect.MakeSlice(field.Type(), len(parts), len(parts)) for i, part := range parts { slice.Index(i).Set(reflect.ValueOf(part)) } field.Set(slice) case reflect.Int64: i, _ := strconv.ParseInt(value, 10, 64) field.SetInt(i) case reflect.Int: i, _ := strconv.Atoi(value) field.SetInt(int64(i)) case reflect.Bool: b, _ := strconv.ParseBool(value) field.SetBool(b) case reflect.Ptr: field.SetString(value) default: log.Fatalf("unsupported type %s", v.FieldByName(fields[0]).Kind()) } } else if len(fields) == 0 { return } else { field := addressableVal.FieldByName(fields[0]) s2 := fieldName[index+1:] setValue2Struct(field, s2, value) } } // flatConfig ... func flatConfig(stut interface{}) map[string]TomlInfo { m := make(map[string]TomlInfo) t := reflect.TypeOf(stut) v := reflect.ValueOf(stut) setTage2Map("", t, v, m, "") return m } func getFieldValueString(fieldValue reflect.Value) string { switch fieldValue.Kind() { case reflect.Slice: sliceLen := fieldValue.Len() strSlice := make([]string, sliceLen) for j := 0; j < sliceLen; j++ { strSlice[j] = fmt.Sprintf("%v", fieldValue.Index(j).Interface()) } return strings.Join(strSlice, ",") default: return fmt.Sprintf("%v", fieldValue.Interface()) } } func setTage2Map(root string, t reflect.Type, v reflect.Value, m map[string]TomlInfo, fieldPath string) { for i := 0; i < t.NumField(); i++ { field := t.Field(i) fieldValue := v.Field(i) tomlVal := field.Tag.Get("toml") if field.Type.Kind() == reflect.Struct { path := fieldPath + field.Name + "." setTage2Map(root+tomlVal+".", field.Type, fieldValue, m, path) continue } if tomlVal == "" { continue } tomlPath := root + tomlVal path := fieldPath + field.Name var v *string str := "" v = &str fieldValueStr := getFieldValueString(fieldValue) usage := field.Tag.Get("usage") m[tomlPath] = TomlInfo{field: field, Value: v, fieldPath: path, fieldValue: fieldValueStr, usage: usage} } } func joinPath(root, path string) string { if filepath.IsAbs(path) { return path } return filepath.Join(root, path) } func formatPath(path string) string { if !filepath.IsAbs(path) || !strings.Contains(path, " ") { return path } quotedPath := fmt.Sprintf(`"%s"`, path) if runtime.GOOS == PlatformWindows { return fmt.Sprintf(`& %s`, quotedPath) } return quotedPath } // isDangerousRoot checks if the given path is a dangerous root directory // that could cause excessive file watching (home dir, root dir, etc.) // Returns true and a description if the path is dangerous. func isDangerousRoot(path string) (bool, string) { // Get absolute path absPath, err := filepath.Abs(path) if err != nil { return false, "" } absPath = filepath.Clean(absPath) // Check root directory if absPath == "/" { return true, "root directory (/)" } // Check home directory home, err := os.UserHomeDir() if err == nil { home = filepath.Clean(home) if absPath == home { return true, "home directory (~)" } } // Check /root (root user's home, in case UserHomeDir returns something else) if absPath == "/root" { return true, "/root directory" } return false, "" } ================================================ FILE: runner/util_linux.go ================================================ package runner import ( "errors" "fmt" "io" "os" "os/exec" "strconv" "strings" "sync" "syscall" "time" ) func (e *Engine) killCmd(cmd *exec.Cmd) (pid int, err error) { pid = cmd.Process.Pid // Start a goroutine to wait for the process to exit done := make(chan struct{}) go func() { _, _ = cmd.Process.Wait() close(done) }() // If not using send_interrupt, just kill immediately if !e.config.Build.SendInterrupt { e.mainDebug("sending SIGKILL to process tree") err = sendSignalToProcessTree(pid, syscall.SIGKILL) <-done // Wait for process to exit return } // Send SIGINT first to allow graceful shutdown e.mainDebug("sending interrupt to process tree") if err = sendSignalToProcessTree(pid, syscall.SIGINT); err != nil { return } killDelay := e.config.killDelay() e.mainDebug("waiting up to %s for graceful shutdown", killDelay.String()) // Wait for either the process to exit gracefully or the kill delay to expire select { case <-done: // Process exited gracefully after SIGINT - excellent! e.mainDebug("process exited gracefully after SIGINT") return case <-time.After(killDelay): // Timeout expired, need to force kill e.mainDebug("kill delay expired, sending SIGKILL") err = sendSignalToProcessTree(pid, syscall.SIGKILL) <-done // Wait for process to exit after SIGKILL return } } func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) { c := exec.Command("/bin/sh", "-c", cmd) // Set Setpgid to create a new process group (not possible when using pty) c.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, } stderr, err := c.StderrPipe() if err != nil { return nil, nil, nil, err } stdout, err := c.StdoutPipe() if err != nil { return nil, nil, nil, err } c.Stdout = os.Stdout c.Stderr = os.Stderr err = c.Start() if err != nil { return nil, nil, nil, err } return c, stdout, stderr, nil } func sendSignalToProcessTree(pid int, sig syscall.Signal) error { descendants, descendantsErr := collectDescendantPIDs(pid) var errs []error if descendantsErr != nil && !errors.Is(descendantsErr, os.ErrNotExist) { errs = append(errs, descendantsErr) } // Try to signal the whole process group first. groupErr := syscall.Kill(-pid, sig) if groupErr != nil && !errors.Is(groupErr, syscall.EPERM) && !errors.Is(groupErr, syscall.ESRCH) { errs = append(errs, groupErr) } // Always signal the root pid as well in case it moved to another process group. procErr := syscall.Kill(pid, sig) if procErr != nil && !errors.Is(procErr, syscall.ESRCH) { errs = append(errs, procErr) } // Send signals to descendants concurrently for better performance var wg sync.WaitGroup var mu sync.Mutex for _, child := range descendants { wg.Add(1) go func(childPID int) { defer wg.Done() if err := syscall.Kill(childPID, sig); err != nil && !errors.Is(err, syscall.ESRCH) { mu.Lock() errs = append(errs, err) mu.Unlock() } }(child) } wg.Wait() if len(errs) == 0 && errors.Is(groupErr, syscall.ESRCH) && errors.Is(procErr, syscall.ESRCH) { return syscall.ESRCH } return errors.Join(errs...) } func collectDescendantPIDs(pid int) ([]int, error) { children, err := readChildPIDs(pid) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, err } var ( allChildren = make([]int, 0, len(children)) errs []error ) for _, child := range children { allChildren = append(allChildren, child) descendants, err := collectDescendantPIDs(child) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } errs = append(errs, err) continue } allChildren = append(allChildren, descendants...) } return allChildren, errors.Join(errs...) } func readChildPIDs(pid int) ([]int, error) { path := fmt.Sprintf("/proc/%d/task/%d/children", pid, pid) data, err := os.ReadFile(path) if err != nil { return nil, err } fields := strings.Fields(string(data)) children := make([]int, 0, len(fields)) for _, field := range fields { childPID, err := strconv.Atoi(field) if err != nil { continue } children = append(children, childPID) } return children, nil } ================================================ FILE: runner/util_linux_test.go ================================================ package runner import ( "errors" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "syscall" "testing" "time" "github.com/stretchr/testify/require" ) func Test_sendSignalToProcessTree_ConcurrentSignalSending(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("requires /proc") } _, b, _, _ := runtime.Caller(0) dir := filepath.Dir(b) err := os.Chdir(dir) if err != nil { t.Fatalf("couldn't change directory: %v", err) } _ = os.Remove("pid") defer os.Remove("pid") e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: false, }, }, } // Start a process tree with multiple children startChan := make(chan *exec.Cmd) go func() { cmd, _, _, err := e.startCmd("sh _testdata/run-many-processes.sh") if err != nil { t.Errorf("failed to start command: %v", err) return } startChan <- cmd if err := cmd.Wait(); err != nil { t.Logf("wait returned: %v", err) } }() cmd := <-startChan pid := cmd.Process.Pid time.Sleep(2 * time.Second) // Send signal using the concurrent implementation err = sendSignalToProcessTree(pid, syscall.SIGKILL) // Should not return an error for successful kill if err != nil && !errors.Is(err, syscall.ESRCH) { t.Errorf("unexpected error from sendSignalToProcessTree: %v", err) } // Verify all processes were killed bytesRead, err := os.ReadFile("pid") require.NoError(t, err) lines := strings.Split(string(bytesRead), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if _, err := strconv.Atoi(line); err != nil { t.Logf("failed to convert str to int %v", err) continue } _, err = exec.Command("ps", "-p", line, "-o", "comm= ").Output() if err == nil { t.Fatalf("process should be killed %v", line) } } } ================================================ FILE: runner/util_test.go ================================================ package runner import ( "fmt" "os" "os/exec" "path/filepath" "reflect" "runtime" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsDirRootPath(t *testing.T) { result := isDir(".") if result != true { t.Errorf("expected '%t' but got '%t'", true, result) } } func TestIsDirMainFile(t *testing.T) { result := isDir("main.go") if result != false { t.Errorf("expected '%t' but got '%t'", true, result) } } func TestIsDirFileNot(t *testing.T) { result := isDir("main.go") if result != false { t.Errorf("expected '%t' but got '%t'", true, result) } } func TestExpandPathWithDot(t *testing.T) { path, _ := expandPath(".") wd, _ := os.Getwd() if path != wd { t.Errorf("expected '%s' but got '%s'", wd, path) } } func TestExpandPathWithHomePath(t *testing.T) { path := "~/.conf" result, _ := expandPath(path) home := os.Getenv("HOME") want := home + path[1:] if result != want { t.Errorf("expected '%s' but got '%s'", want, result) } } func TestNormalizeIncludeDirOutsideRoot(t *testing.T) { t.Parallel() root := t.TempDir() parent := filepath.Dir(root) external := filepath.Join(parent, "pkg") cfg := &Config{ Root: root, Build: cfgBuild{ IncludeDir: []string{"../pkg"}, }, } cfg.Build.normalizeIncludeDirs(cfg.Root) require.Empty(t, cfg.Build.includeDirAbs) require.Equal(t, []string{filepath.Clean(external)}, cfg.Build.extraIncludeDirs) engine := &Engine{config: cfg} isIn, walk := engine.checkIncludeDir(filepath.Join(root, "runner")) require.True(t, isIn) require.True(t, walk) } func TestCheckIncludeDirRestrictsWithinRoot(t *testing.T) { t.Parallel() root := t.TempDir() runnerDir := filepath.Join(root, "runner") require.NoError(t, os.Mkdir(runnerDir, 0o755)) otherDir := filepath.Join(root, "other") require.NoError(t, os.Mkdir(otherDir, 0o755)) cfg := &Config{ Root: root, Build: cfgBuild{ IncludeDir: []string{"runner"}, }, } cfg.Build.normalizeIncludeDirs(cfg.Root) engine := &Engine{config: cfg} isIn, walk := engine.checkIncludeDir(runnerDir) require.True(t, isIn) require.True(t, walk) isIn, walk = engine.checkIncludeDir(otherDir) require.False(t, isIn) require.False(t, walk) } func TestFileChecksum(t *testing.T) { t.Parallel() tests := []struct { name string fileContents []byte expectedChecksum string expectedChecksumError string }{ { name: "empty", fileContents: []byte(``), expectedChecksum: "", expectedChecksumError: "empty file, forcing rebuild without updating checksum", }, { name: "simple", fileContents: []byte(`foo`), expectedChecksum: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", expectedChecksumError: "", }, { name: "binary", fileContents: []byte{0xF}, // invalid UTF-8 codepoint expectedChecksum: "dc0e9c3658a1a3ed1ec94274d8b19925c93e1abb7ddba294923ad9bde30f8cb8", expectedChecksumError: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { f, err := os.CreateTemp("", "") if err != nil { t.Fatalf("couldn't create temp file for test: %v", err) } defer func() { if err := f.Close(); err != nil { t.Errorf("error closing temp file: %v", err) } if err := os.Remove(f.Name()); err != nil { t.Errorf("error removing temp file: %v", err) } }() _, err = f.Write(test.fileContents) if err != nil { t.Fatalf("couldn't write to temp file for test: %v", err) } checksum, err := fileChecksum(f.Name()) if err != nil && err.Error() != test.expectedChecksumError { t.Errorf("expected '%s' but got '%s'", test.expectedChecksumError, err.Error()) } if checksum != test.expectedChecksum { t.Errorf("expected '%s' but got '%s'", test.expectedChecksum, checksum) } }) } } func TestChecksumMap(t *testing.T) { t.Parallel() m := &checksumMap{m: make(map[string]string, 3)} if !m.updateFileChecksum("foo.txt", "abcxyz") { t.Errorf("expected no entry for foo.txt, but had one") } if m.updateFileChecksum("foo.txt", "abcxyz") { t.Errorf("expected matching entry for foo.txt") } if !m.updateFileChecksum("foo.txt", "123456") { t.Errorf("expected matching entry for foo.txt") } if !m.updateFileChecksum("bar.txt", "123456") { t.Errorf("expected no entry for bar.txt, but had one") } } func TestAdaptToVariousPlatforms(t *testing.T) { t.Parallel() config := &Config{ Build: cfgBuild{ Bin: "tmp\\main.exe -dev", }, } adaptToVariousPlatforms(config) if config.Build.Bin != "tmp\\main.exe -dev" { t.Errorf("expected '%s' but got '%s'", "tmp\\main.exe -dev", config.Build.Bin) } } func Test_killCmd_SendInterrupt_false(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires sh") } _, b, _, _ := runtime.Caller(0) // Root folder of this project dir := filepath.Dir(b) err := os.Chdir(dir) if err != nil { t.Fatalf("couldn't change directory: %v", err) } // clean file before test os.Remove("pid") defer os.Remove("pid") e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: false, }, }, } startChan := make(chan struct { pid int cmd *exec.Cmd }) go func() { cmd, _, _, err := e.startCmd("sh _testdata/run-many-processes.sh") if err != nil { t.Errorf("failed to start command: %v", err) return } pid := cmd.Process.Pid t.Logf("process pid is %v", pid) startChan <- struct { pid int cmd *exec.Cmd }{pid: pid, cmd: cmd} if err := cmd.Wait(); err != nil { t.Logf("failed to wait command: %v", err) } t.Logf("wait finished") }() resp := <-startChan t.Logf("process started. checking pid %v", resp.pid) time.Sleep(2 * time.Second) t.Logf("%v", resp.cmd.Process.Pid) pid, _ := e.killCmd(resp.cmd) t.Logf("%v was been killed", pid) // check processes were being killed // read pids from file bytesRead, err := os.ReadFile("pid") require.NoError(t, err) lines := strings.Split(string(bytesRead), "\n") for _, line := range lines { _, err := strconv.Atoi(line) if err != nil { t.Logf("failed to convert str to int %v", err) continue } _, err = exec.Command("ps", "-p", line, "-o", "comm= ").Output() if err == nil { t.Fatalf("process should be killed %v", line) } } } func Test_killCmd_KillsDetachedChildren(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("requires /proc") } _, b, _, _ := runtime.Caller(0) dir := filepath.Dir(b) err := os.Chdir(dir) if err != nil { t.Fatalf("couldn't change directory: %v", err) } _ = os.Remove("pid") defer os.Remove("pid") e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: false, }, }, } startChan := make(chan *exec.Cmd) go func() { cmd, _, _, err := e.startCmd("sh _testdata/run-detached-process.sh") if err != nil { t.Errorf("failed to start command: %v", err) return } startChan <- cmd if err := cmd.Wait(); err != nil { t.Logf("failed to wait command: %v", err) } }() cmd := <-startChan time.Sleep(2 * time.Second) if _, err := e.killCmd(cmd); err != nil { t.Fatalf("failed to kill command: %v", err) } bytesRead, err := os.ReadFile("pid") require.NoError(t, err) lines := strings.Split(string(bytesRead), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } if _, err := strconv.Atoi(line); err != nil { t.Logf("failed to convert str to int %v", err) continue } _, err = exec.Command("ps", "-p", line, "-o", "comm= ").Output() if err == nil { t.Fatalf("process should be killed %v", line) } } } func TestGetStructureFieldTagMap(t *testing.T) { t.Parallel() c := Config{} tagMap := flatConfig(c) assert.NotEmpty(t, tagMap) for _, i2 := range tagMap { fmt.Printf("%v\n", i2.fieldPath) } } func TestSetStructValue(t *testing.T) { t.Parallel() c := Config{} v := reflect.ValueOf(&c) setValue2Struct(v, "TmpDir", "asdasd") assert.Equal(t, "asdasd", c.TmpDir) } func TestNestStructValue(t *testing.T) { t.Parallel() c := Config{} v := reflect.ValueOf(&c) setValue2Struct(v, "Build.Cmd", "asdasd") assert.Equal(t, "asdasd", c.Build.Cmd) } func TestNestStructArrayValue(t *testing.T) { t.Parallel() c := Config{} v := reflect.ValueOf(&c) setValue2Struct(v, "Build.ExcludeDir", "dir1,dir2") assert.Equal(t, []string{"dir1", "dir2"}, c.Build.ExcludeDir) } func TestNestStructArrayValueOverride(t *testing.T) { t.Parallel() c := Config{ Build: cfgBuild{ ExcludeDir: []string{"default1", "default2"}, }, } v := reflect.ValueOf(&c) setValue2Struct(v, "Build.ExcludeDir", "dir1,dir2") assert.Equal(t, []string{"dir1", "dir2"}, c.Build.ExcludeDir) } func TestCheckIncludeFile(t *testing.T) { t.Parallel() e := Engine{ config: &Config{ Build: cfgBuild{ IncludeFile: []string{"main.go"}, }, }, } assert.True(t, e.checkIncludeFile("main.go")) assert.False(t, e.checkIncludeFile("no.go")) assert.False(t, e.checkIncludeFile(".")) } func TestIsIncludeExt(t *testing.T) { e := Engine{ config: &Config{ Build: cfgBuild{ IncludeExt: []string{"go", "html"}, }, }, } assert.True(t, e.isIncludeExt("main.go")) assert.True(t, e.isIncludeExt("/path/to/file.html")) assert.False(t, e.isIncludeExt("main.js")) assert.False(t, e.isIncludeExt("file")) } func TestIsIncludeExtWildcard(t *testing.T) { tmpDir := t.TempDir() binPath := filepath.Join(tmpDir, "tmp", "main") e := Engine{ config: &Config{ Root: tmpDir, Build: cfgBuild{ IncludeExt: []string{"*"}, Entrypoint: entrypoint{binPath}, }, }, } // Wildcard should match all file extensions assert.True(t, e.isIncludeExt("main.go")) assert.True(t, e.isIncludeExt("/path/to/file.html")) assert.True(t, e.isIncludeExt("main.js")) assert.True(t, e.isIncludeExt("file.css")) assert.True(t, e.isIncludeExt("file")) // files without extension assert.True(t, e.isIncludeExt("/path/noext")) // files without extension assert.False(t, e.isIncludeExt(binPath)) // binary file should be excluded assert.True(t, e.isIncludeExt("some/other/bin")) // other files without extension are ok } func TestIsIncludeExtWildcardWithSpaces(t *testing.T) { e := Engine{ config: &Config{ Build: cfgBuild{ IncludeExt: []string{" * "}, Entrypoint: entrypoint{"/tmp/main"}, }, }, } // Wildcard with spaces should still work assert.True(t, e.isIncludeExt("main.go")) assert.True(t, e.isIncludeExt("file.html")) } func TestIsBinPath(t *testing.T) { tmpDir := t.TempDir() binPath := filepath.Join(tmpDir, "tmp", "main") e := Engine{ config: &Config{ Root: tmpDir, Build: cfgBuild{ Entrypoint: entrypoint{binPath}, }, }, } // Test matching path returns true assert.True(t, e.isBinPath(binPath)) // Test non-matching paths return false assert.False(t, e.isBinPath(filepath.Join(tmpDir, "other", "file"))) assert.False(t, e.isBinPath("unrelated.go")) } func TestIsBinPathEmptyBinPath(t *testing.T) { // Test when binPath is empty (no entrypoint configured) e := Engine{ config: &Config{ Build: cfgBuild{ Entrypoint: entrypoint{}, // empty entrypoint }, }, } // Should return false when binPath is empty assert.False(t, e.isBinPath("/some/path")) assert.False(t, e.isBinPath("main.go")) } func TestJoinPathRelative(t *testing.T) { t.Parallel() root, err := filepath.Abs("test") if err != nil { t.Fatalf("couldn't get absolute path for testing: %v", err) } result := joinPath(root, "x") assert.Equal(t, result, filepath.Join(root, "x")) } func TestJoinPathAbsolute(t *testing.T) { root, err := filepath.Abs("test") if err != nil { t.Fatalf("couldn't get absolute path for testing: %v", err) } path, err := filepath.Abs("x") if err != nil { t.Fatalf("couldn't get absolute path for testing: %v", err) } result := joinPath(root, path) assert.Equal(t, result, path) } func TestFormatPath(t *testing.T) { t.Parallel() type testCase struct { name string path string expected string } runTests := func(t *testing.T, tests []testCase) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatPath(tt.path) if result != tt.expected { t.Errorf("formatPath(%q) = %q, want %q", tt.path, result, tt.expected) } }) } } t.Run("PathPlatformSpecific", func(t *testing.T) { if runtime.GOOS == PlatformWindows { // Windows-specific tests tests := []testCase{ { name: "Windows style absolute path with spaces", path: `C:\My Documents\My Project\tmp\app.exe`, expected: `& "C:\My Documents\My Project\tmp\app.exe"`, }, { name: "Windows style relative path with spaces", path: `My Project\tmp\app.exe`, expected: `My Project\tmp\app.exe`, }, { name: "Windows style absolute path without spaces", path: `C:\Documents\Project\tmp\app.exe`, expected: `C:\Documents\Project\tmp\app.exe`, }, } runTests(t, tests) } else { // Unix-specific tests tests := []testCase{ { name: "Unix style absolute path with spaces", path: `/usr/local/my project/tmp/main`, expected: `"/usr/local/my project/tmp/main"`, }, { name: "Unix style relative path with spaces", path: "./my project/tmp/main", expected: "./my project/tmp/main", }, { name: "Unix style absolute path without spaces", path: `/usr/local/project/tmp/main`, expected: `/usr/local/project/tmp/main`, }, } runTests(t, tests) } }) t.Run("CommonCases", func(t *testing.T) { tests := []testCase{ { name: "Empty path", path: "", expected: "", }, { name: "Simple path", path: "main.go", expected: "main.go", }, { name: "TestShouldIncludeIncludedFile", path: "sh main.sh", expected: "sh main.sh", }, } runTests(t, tests) }) } // Test_killCmd_SendInterrupt_FastGracefulExit is a regression test for issue #671. // It verifies that when a process exits quickly after receiving SIGINT, Air detects // this and returns immediately instead of waiting the full kill_delay. // // This optimization was implemented in commit 4d26204 (2024-11-01) by Isak Styf. // Before the fix, Air would always sleep for the full kill_delay (wasting time). // After the fix, Air uses goroutines to detect process exit and returns early. // // Related: https://github.com/air-verse/air/issues/671 func Test_killCmd_SendInterrupt_FastGracefulExit(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("send_interrupt not supported on windows") } e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: true, KillDelay: 2 * time.Second, // Set high to make the waste observable }, }, } // Process that exits immediately on SIGINT // trap "exit 0" INT means: exit cleanly when receiving SIGINT // sleep 100 means: if no signal, run for 100 seconds cmd, _, _, err := e.startCmd(`sh -c 'trap "exit 0" INT; sleep 100'`) require.NoError(t, err, "failed to start command") // Give the process a moment to start and set up the signal handler time.Sleep(100 * time.Millisecond) start := time.Now() pid, err := e.killCmd(cmd) elapsed := time.Since(start) require.NoError(t, err, "killCmd should succeed") assert.Positive(t, pid, "should return valid pid") // Core assertion: should complete in much less than 2 seconds // Process exits immediately on SIGINT, so should finish in <500ms // With the fix (commit 4d26204), this should PASS // Without the fix, this would FAIL (would take 2+ seconds) assert.Less(t, elapsed, 500*time.Millisecond, "killCmd should return quickly when process exits gracefully on SIGINT, "+ "but took %v (expected < 500ms). Regression of issue #671!", elapsed) t.Logf("✅ PASS: Process exited gracefully in %v (kill_delay was 2s, saved ~%.1fs)", elapsed, 2.0-elapsed.Seconds()) } // Test_killCmd_SendInterrupt_IgnoresSIGINT is a regression test for issue #671. // It verifies that processes which ignore SIGINT are still killed with SIGKILL // after kill_delay. This ensures the optimization (commit 4d26204) doesn't break // the fallback behavior for misbehaving processes. // // Related: https://github.com/air-verse/air/issues/671 func Test_killCmd_SendInterrupt_IgnoresSIGINT(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("send_interrupt not supported on windows") } e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: true, KillDelay: 500 * time.Millisecond, }, }, } // Process that ignores SIGINT // trap "" INT means: ignore SIGINT signal cmd, _, _, err := e.startCmd(`sh -c 'trap "" INT; sleep 100'`) require.NoError(t, err, "failed to start command") // Give the process a moment to start and set up the signal handler time.Sleep(100 * time.Millisecond) start := time.Now() pid, err := e.killCmd(cmd) elapsed := time.Since(start) require.NoError(t, err, "killCmd should succeed") assert.Positive(t, pid, "should return valid pid") // Should wait at least kill_delay before sending SIGKILL assert.GreaterOrEqual(t, elapsed, 500*time.Millisecond, "killCmd should wait at least kill_delay when process ignores SIGINT") // But should not wait too long after SIGKILL assert.Less(t, elapsed, 1*time.Second, "killCmd should not wait too long after sending SIGKILL, "+ "but took %v", elapsed) t.Logf("✅ PASS: Process ignored SIGINT, killed with SIGKILL after %v (expected behavior)", elapsed) } // Test_killCmd_SendInterrupt_SlowGracefulExit is a regression test for issue #671. // It verifies that when a process takes some time to clean up after receiving // SIGINT but still exits within kill_delay, Air returns as soon as the process exits // (not waiting the full kill_delay). // // This optimization was implemented in commit 4d26204 (2024-11-01). // Related: https://github.com/air-verse/air/issues/671 func Test_killCmd_SendInterrupt_SlowGracefulExit(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("send_interrupt not supported on windows") } e := Engine{ config: &Config{ Build: cfgBuild{ SendInterrupt: true, KillDelay: 1 * time.Second, }, }, } // Process that takes 300ms to exit after SIGINT (simulating cleanup) // trap "sleep 0.3; exit 0" INT means: when SIGINT received, sleep 300ms then exit cmd, _, _, err := e.startCmd(`sh -c 'trap "sleep 0.3; exit 0" INT; sleep 100'`) require.NoError(t, err, "failed to start command") // Give the process a moment to start and set up the signal handler time.Sleep(100 * time.Millisecond) start := time.Now() pid, err := e.killCmd(cmd) elapsed := time.Since(start) require.NoError(t, err, "killCmd should succeed") assert.Positive(t, pid, "should return valid pid") // Should wait at least for the process cleanup time (~300ms) assert.Greater(t, elapsed, 250*time.Millisecond, "should wait at least for process cleanup time (~300ms)") // Should return shortly after process exits (~300-500ms) // With the fix (commit 4d26204), this should PASS // Without the fix, this would FAIL (would take 1+ seconds) assert.Less(t, elapsed, 600*time.Millisecond, "killCmd should return soon after process exits, "+ "but took %v (expected ~300-500ms). Regression of issue #671!", elapsed) t.Logf("✅ PASS: Process exited gracefully in %v after cleanup (kill_delay was 1s, saved ~%.1fs)", elapsed, 1.0-elapsed.Seconds()) } func TestIsDangerousRoot(t *testing.T) { t.Parallel() homeDir, err := os.UserHomeDir() require.NoError(t, err, "failed to get user home directory") var tests []struct { name string path string isDangerous bool description string } if runtime.GOOS == "windows" { tests = []struct { name string path string isDangerous bool description string }{ { name: "user home directory", path: homeDir, isDangerous: true, description: "home directory (~)", }, { name: "normal project directory", path: filepath.Join(homeDir, "work", "myapp"), isDangerous: false, description: "", }, { name: "current directory in project", path: ".", isDangerous: false, description: "", }, { name: "subdirectory of home", path: filepath.Join(homeDir, "projects", "myapp"), isDangerous: false, description: "", }, } } else { tests = []struct { name string path string isDangerous bool description string }{ { name: "root directory", path: "/", isDangerous: true, description: "root directory (/)", }, { name: "root user home", path: "/root", isDangerous: true, description: "/root directory", }, { name: "user home directory", path: homeDir, isDangerous: true, description: "home directory (~)", }, { name: "normal project directory", path: "/home/user/myproject", isDangerous: false, description: "", }, { name: "tmp directory", path: "/tmp/test-project", isDangerous: false, description: "", }, { name: "current directory in project", path: ".", isDangerous: false, description: "", }, { name: "subdirectory of home", path: filepath.Join(homeDir, "projects", "myapp"), isDangerous: false, description: "", }, } } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isDangerous, desc := isDangerousRoot(tt.path) assert.Equal(t, tt.isDangerous, isDangerous, "isDangerous mismatch for path %s", tt.path) if tt.isDangerous { assert.Equal(t, tt.description, desc, "description mismatch for path %s", tt.path) } }) } } ================================================ FILE: runner/util_unix.go ================================================ //go:build unix && !linux package runner import ( "io" "os" "os/exec" "syscall" "time" ) func (e *Engine) killCmd(cmd *exec.Cmd) (pid int, err error) { pid = cmd.Process.Pid // Start a goroutine to wait for the process to exit done := make(chan struct{}) go func() { _, _ = cmd.Process.Wait() close(done) }() // If not using send_interrupt, just kill immediately if !e.config.Build.SendInterrupt { e.mainDebug("sending SIGKILL to process %d", pid) // https://stackoverflow.com/questions/22470193/why-wont-go-kill-a-child-process-correctly err = syscall.Kill(-pid, syscall.SIGKILL) <-done // Wait for process to exit return } // Send SIGINT first to allow graceful shutdown e.mainDebug("sending interrupt to process %d", pid) if err = syscall.Kill(-pid, syscall.SIGINT); err != nil { return } killDelay := e.config.killDelay() e.mainDebug("waiting up to %s for graceful shutdown", killDelay.String()) // Wait for either the process to exit gracefully or the kill delay to expire select { case <-done: // Process exited gracefully after SIGINT - excellent! e.mainDebug("process exited gracefully after SIGINT") return case <-time.After(killDelay): // Timeout expired, need to force kill e.mainDebug("kill delay expired, sending SIGKILL") // https://stackoverflow.com/questions/22470193/why-wont-go-kill-a-child-process-correctly err = syscall.Kill(-pid, syscall.SIGKILL) <-done // Wait for process to exit after SIGKILL return } } func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) { c := exec.Command("/bin/sh", "-c", cmd) // because using pty cannot have same pgid c.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, } stderr, err := c.StderrPipe() if err != nil { return nil, nil, nil, err } stdout, err := c.StdoutPipe() if err != nil { return nil, nil, nil, err } c.Stdout = os.Stdout c.Stderr = os.Stderr err = c.Start() if err != nil { return nil, nil, nil, err } return c, stdout, stderr, nil } ================================================ FILE: runner/util_windows.go ================================================ //go:build windows package runner import ( "io" "os" "os/exec" "strconv" "strings" "syscall" "golang.org/x/sys/windows" ) func (e *Engine) killCmd(cmd *exec.Cmd) (pid int, err error) { pid = cmd.Process.Pid // On Windows, SIGINT is not supported for process trees // Use TASKKILL to forcefully terminate the process hierarchy if e.config.Build.SendInterrupt { e.mainLog("send_interrupt is not supported on Windows, using TASKKILL instead") } // Single TASKKILL execution to avoid double-kill bug e.mainDebug("sending TASKKILL to process tree") killCmd := exec.Command("TASKKILL", "/F", "/T", "/PID", strconv.Itoa(pid)) // Hide the taskkill console window killCmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW, } err = killCmd.Run() // Wait for process to terminate and release resources _, _ = cmd.Process.Wait() return pid, err } func (e *Engine) startCmd(cmd string) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) { var err error if !strings.Contains(cmd, ".exe") { e.runnerLog("CMD will not recognize non .exe file for execution, path: %s", cmd) } // Keep PowerShell to avoid cmd.exe sound issues (#707) // Use -NoProfile and -NonInteractive for better performance c := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd) stderr, err := c.StderrPipe() if err != nil { return nil, nil, nil, err } stdout, err := c.StdoutPipe() if err != nil { return nil, nil, nil, err } c.Stdout = os.Stdout c.Stderr = os.Stderr err = c.Start() if err != nil { return nil, nil, nil, err } return c, stdout, stderr, err } ================================================ FILE: runner/util_windows_test.go ================================================ package runner import ( "runtime" "strings" "testing" ) func TestAdaptToVariousPlatformsFullBinWindows(t *testing.T) { if runtime.GOOS != PlatformWindows { t.Skip("windows-only behavior") } t.Parallel() tests := []struct { name string fullBin string expected string }{ { name: "exe already", fullBin: `.\tmp\main.exe`, expected: `.\tmp\main.exe`, }, { name: "append exe", fullBin: `.\tmp\main`, expected: `.\tmp\main.exe`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := &Config{ Build: cfgBuild{ FullBin: tt.fullBin, }, } adaptToVariousPlatforms(config) if config.Build.FullBin != tt.expected { t.Fatalf("expected full_bin %q, got %q", tt.expected, config.Build.FullBin) } if strings.HasPrefix(strings.ToLower(config.Build.FullBin), "start ") { t.Fatalf("unexpected start prefix in full_bin: %q", config.Build.FullBin) } }) } } ================================================ FILE: runner/watcher.go ================================================ package runner import ( "time" "github.com/gohugoio/hugo/watcher/filenotify" ) func newWatcher(cfg *Config) (filenotify.FileWatcher, error) { if !cfg.Build.Poll { return filenotify.NewEventWatcher() } // Get the poll interval from the config. interval := cfg.Build.PollInterval // Make sure the interval is at least 500ms. if interval < 500 { interval = 500 } pollInterval := time.Duration(interval) * time.Millisecond return filenotify.NewPollingWatcher(pollInterval), nil } ================================================ FILE: runner/worker.js ================================================ (() => { const ports = new Set(); let sse = null; let terminationTimer = null; let reconnectTimer = null; let reconnectAttempts = 0; self.onconnect = (event) => { const port = event.ports[0]; ports.add(port); if (terminationTimer) { // We're still alive cancelTermination(); } // Initialize the EventSource once if (!sse) { initSSE(); } // Handle graceful disconnect message from port port.onmessage = (e) => { if (e.data === 'disconnect') { ports.delete(port); if (ports.size === 0) { scheduleTermination(); } } }; port.start(); }; function initSSE() { if (sse) { return; } sse = new EventSource("/__air_internal/sse"); sse.addEventListener('reload', () => { broadcast({ type: 'reload' }); }); sse.addEventListener('build-failed', (e) => { broadcast({ type: 'build-failed', data: e.data }); }); sse.onopen = () => { reconnectAttempts = 0; }; sse.onerror = () => { if (sse) { sse.close(); sse = null; } scheduleReconnect(); }; } function scheduleReconnect() { if (reconnectTimer || ports.size === 0) { return; } const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); reconnectAttempts += 1; reconnectTimer = setTimeout(() => { reconnectTimer = null; if (ports.size === 0) { return; } initSSE(); }, delay); } function clearReconnect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempts = 0; } function broadcast(data) { ports.forEach(port => { try { port.postMessage(data) } catch (e) { // This port is dead so we remove it. If this was the last port, schedule termination. ports.delete(port); if (ports.size === 0) { scheduleTermination(); } } }); } function cancelTermination() { clearTimeout(terminationTimer); terminationTimer = null; } function scheduleTermination() { if (terminationTimer) { // Already scheduled return } clearReconnect(); terminationTimer = setTimeout(() => { if (sse) { sse.close(); sse = null; } clearReconnect(); self.close(); }, 3000); } })(); ================================================ FILE: smoke_test/check_rebuild/go.mod ================================================ module air.sample.com go 1.17 ================================================ FILE: smoke_test/check_rebuild/go.sum ================================================ ================================================ FILE: smoke_test/check_rebuild/main.go ================================================ package main import ( "log" "net/http" ) func main() { log.Fatal(http.ListenAndServe(":8080", nil)) } ================================================ FILE: smoke_test/smoke_test.py ================================================ import os from pexpect.popen_spawn import PopenSpawn print(os.getcwd()) os.chdir(os.getcwd() + "\check_rebuild") print(os.getcwd()) child = PopenSpawn("air") child.expect a = child.expect("running", timeout=300) if a == 0: with open("main.go", "a") as f: f.write("\n\n") else: exit(0) a = child.expect("running", timeout=300) if a == 0: print("::set-output name=value::PASS") else: print("::set-output name=value::FAIL") exit(0) ================================================ FILE: version.go ================================================ package main var ( airVersion string goVersion string )