diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d10fcf0..13afe1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,30 @@ jobs: go run tools/release-notes.go "$VERSION" CHANGELOG.md "$RUNNER_TEMP/release-notes.md" cat "$RUNNER_TEMP/release-notes.md" + # Build every binary once before anything is pushed, so that the license + # check below runs while a failure still leaves the repository untouched. + # The third-party licenses are generated by a build hook, and nothing + # else would notice a hook that left a binary without them. A snapshot + # publishes nothing; the release below builds the same commit with the + # same pinned go-licenses from the same immutable modules. + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: build --snapshot --clean + + # spf13/cobra is linked on every platform, so a set without it is a set + # the hook failed to generate. The linux/amd64 binary is the one this + # runner can execute; the others embed their sets the same way. + - name: Check the embedded licenses + run: | + for dir in internal/licenses/embed/*/; do + if [ ! -d "${dir}third-party/github.com/spf13/cobra" ]; then + echo "no third-party licenses generated in $dir" >&2 + exit 1 + fi + done + dist/tfscli_linux_amd64*/tfscli licenses | grep -qx ' github.com/spf13/cobra' + # A push made with GITHUB_TOKEN does not start another workflow, so the # release commit will not set off a redundant CI run. - name: Commit, tag, and push diff --git a/.gitignore b/.gitignore index 3645f92..bfd7ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,16 @@ /tfscli.exe /dist/ +# The third-party licenses the release build generates for each platform; the +# PLACEHOLDER beside each third-party/ is committed. +/internal/licenses/embed/*/third-party/ + # Test and coverage artifacts *.test *.out coverage.html -# A local config made from config.example.json — personal settings, never -# committed. Credentials are not kept here; they live in auth.json. +# A local config file — personal settings, never committed. Credentials are not kept here; they live in auth.json. config.json # IDE diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0517c4d..ad73ff1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,14 @@ version: 2 project_name: tfscli +before: + hooks: + # go-licenses generates the third-party licenses each binary embeds (see + # the build pre-hook). It is installed for the platform goreleaser runs on: + # `go run` in the pre-hook would build it for the target's GOOS instead, + # and a binary for another platform cannot be executed. + - go install github.com/google/go-licenses/v2@v2.0.1 + builds: - id: tfscli main: ./cmd/tfscli @@ -12,6 +20,19 @@ builds: - CGO_ENABLED=0 ldflags: - -X main.version={{ .Version }} -X main.commit={{ .Commit }} + # The licenses of the third-party modules linked into this target, saved + # where internal/licenses embeds them for it. GOOS and GOARCH make + # go-licenses follow the target's dependencies, which differ by platform. + # The targets build in parallel, and each writes to a directory of its + # own; --force replaces what an earlier build left there. A hook runs + # without a shell, so nothing here may rely on one. + hooks: + pre: + - cmd: go-licenses save ./cmd/tfscli --save_path=internal/licenses/embed/{{ .Os }}-{{ .Arch }}/third-party --ignore github.com/dpleshakov/tfscli --force + env: + - "GOOS={{ .Os }}" + - "GOARCH={{ .Arch }}" + output: true goos: - linux - windows @@ -27,11 +48,14 @@ archives: format_overrides: - goos: windows formats: [zip] + # The archive unpacks into a directory named like it, rather than + # scattering its files into the directory it is unpacked in. + wrap_in_directory: true + # Only what a user needs beside the binary. The documentation lives on + # GitHub, and the release page carries the changelog section; the + # third-party licenses are embedded in the binary (tfscli licenses). files: - LICENSE - - README.md - - CHANGELOG.md - - config.example.json # The agent skill ships with the binary so that an AI agent that unpacks # the archive can be told how to use tfscli without a second download. # The directory is kept whole: it is copied as-is into a skills path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c295ba..b0f2024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `tfscli licenses` prints the components included in the binary and the license text of each: tfscli itself, the Go standard library, and, in the release builds, the third-party modules. + ### Changed +- Release archives now unpack into a directory of their own name, `tfscli---/`, holding only the binary, `LICENSE`, and the agent skill; `README.md`, `CHANGELOG.md`, and `config.example.json` are no longer included. - Every command except `tfscli auth login`, when no API version is configured, now first asks the server with an `OPTIONS` request which version its resource supports and sends that version, so `wit work-items get-batch` and `wit wiql query-by-wiql` no longer fail with "No api-version was supplied" on servers that require one. - A request sent without an API version because the server did not answer that question is now noted under `--verbose`, and if the server refuses it for the missing version, the `config` error names `apiVersion`, `TFSCLI_API_VERSION`, and `--api-version`. diff --git a/CLAUDE.md b/CLAUDE.md index 31d6b0a..3157e46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The workflow is defined by four process skills kept in the repository under `.cl ## Project state -The MVP is implemented and released: `tfscli auth login` stores the credential, `tfscli wit work-items get` reads one work item, `tfscli wit work-items list` and `tfscli wit work-items get-batch` read several work items by id, `tfscli wit wiql query-by-wiql` finds work item ids with a WIQL query, and the module has one package per architecture module (see the "Project Structure" section of `docs/architecture.md`). Known defects are listed in the "Known limitations" section of `README.md`. Accepted work that has not started is listed in `docs/backlog.md`; work in progress goes through tasks files per the `tasks` skill. +The MVP is implemented and released: `tfscli auth login` stores the credential, `tfscli wit work-items get` reads one work item, `tfscli wit work-items list` and `tfscli wit work-items get-batch` read several work items by id, `tfscli wit wiql query-by-wiql` finds work item ids with a WIQL query, `tfscli licenses` prints the license texts embedded in the binary, and the module has one package per architecture module (see the "Project Structure" section of `docs/architecture.md`). Known defects are listed in the "Known limitations" section of `README.md`. Accepted work that has not started is listed in `docs/backlog.md`; work in progress goes through tasks files per the `tasks` skill. Read these before the corresponding kind of change (the full list of project documents is in "Project documents" in `CONTRIBUTING.md`): @@ -30,7 +30,7 @@ These come from `project-brief.md` and override casual feature requests. If a ch - **Target is on-prem TFS / Azure DevOps Server; the API version is negotiated.** Without a configured version, each request is preceded by `OPTIONS {url}/{collection}/_apis` and carries the version the server lists for its resource; when the negotiation fails, the request goes out without a version, as before. A configured version is sent as is. `auth login` never sends one. Cloud Azure DevOps Services may work incidentally but is not tested or supported. - **Do not narrow server compatibility.** A change must not stop tfscli from working with a TFS / Azure DevOps Server version it worked with before, unless that is an explicitly discussed and recorded decision (see "Server compatibility is preserved" in `project-brief.md`). - **Works out of the box; errors lead the way.** Assume the user has read nothing and configured nothing. Do not add a required setting where a working default exists, and do not guess where the answer is ambiguous — ask the user instead. Every error a first-time user can hit names the concrete next step, and says when only a person can take it (see "Works out of the box; errors lead the way" in `project-brief.md`). -- **Minimal dependencies.** Total external deps are intentionally three: `cobra`, `html-to-markdown/v2`, and `golang.org/x/term` (hidden PAT input for `auth login`). HTTP via `net/http`, JSON via `encoding/json`, config via `encoding/json`. Don't pull in `viper`, `resty`, retry libraries, or YAML/TOML parsers without a strong reason and explicit discussion. +- **Minimal dependencies.** Total external deps are intentionally three: `cobra`, `html-to-markdown/v2`, and `golang.org/x/term` (hidden PAT input for `auth login`). HTTP via `net/http`, JSON via `encoding/json`, config via `encoding/json`. go-licenses, which generates the license notices at release time, is a build tool and not a dependency. Don't pull in `viper`, `resty`, retry libraries, or YAML/TOML parsers without a strong reason and explicit discussion. ## Command shape @@ -38,7 +38,7 @@ These come from `project-brief.md` and override casual feature requests. If a ch tfscli [flags] [arguments] ``` -The area and the resource are the segments of the operation's page path in the REST API reference (`.../wit/work-items/get-work-item` is `wit work-items get`); the action is the operation name without the resource name. `tfscli auth login` is a local command and lies outside this scheme. The full rule is in "Follow TFS API structure" in `project-brief.md`. +The area and the resource are the segments of the operation's page path in the REST API reference (`.../wit/work-items/get-work-item` is `wit work-items get`); the action is the operation name without the resource name. `tfscli auth login` and `tfscli licenses` are local commands and lie outside this scheme. The full rule is in "Follow TFS API structure" in `project-brief.md`. Commands (from the brief): @@ -54,9 +54,9 @@ tfscli wit wiql query-by-wiql -p MyProject --query "SELECT [System.Id] FROM Work ## Build / test commands -Everything the project verifies runs through the `Makefile` — `build`, `lint`, `test`, `check`, `clean` — and CI runs `make check` verbatim, so a green `make check` locally is the whole gate. A single test is `go test -run TestName ./path/to/pkg`. +Everything the project verifies runs through the `Makefile` — `build`, `lint`, `test`, `licenses`, `check`, `clean` — and CI runs `make check` verbatim, so a green `make check` locally is the whole gate. A single test is `go test -run TestName ./path/to/pkg`. -Recipes never assume a Unix shell: file removal goes through `git clean`, and the coverage check and the changelog handling live in Go programs under `tools/`, tagged `//go:build ignore` so that `go build ./...`, `go vet ./...`, and `go test ./...` do not see them. +Recipes never assume a Unix shell: file removal goes through `git clean`, and the coverage check, the license check, and the changelog handling live in Go programs under `tools/`, tagged `//go:build ignore` so that `go build ./...`, `go vet ./...`, and `go test ./...` do not see them. A release is made by running the `Release` workflow from the Actions tab with a version number; it verifies, rewrites `CHANGELOG.md`, commits, tags, pushes, and builds a draft release with goreleaser, which is run by the workflow and not through the `Makefile`. Nothing about a release is done by hand except pressing Publish. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ce57cc..ccb487f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,9 @@ The developer handbook for this repository. `README.md` documents the tool for i | `make build` | `go vet`, `go test`, then builds the binary into the repository root | While working on the code | | `make lint` | `go mod tidy -diff`, which fails if `go.mod` or `go.sum` drifted from the imports, and `golangci-lint run` | Before committing | | `make test` | Measures coverage of `internal/...` and fails below 85% | While working on tests | -| `make check` | `build`, `lint`, and `test` | Before pushing — CI runs exactly this | -| `make clean` | Removes the binary, `coverage.out`, and `dist/` through `git clean`, touching nothing else | Any time | +| `make licenses` | `go-licenses check` for each of the six release platforms (`tools/check-licenses.go`), which fails if a dependency linked into the binary is under a license other than MIT, BSD-3-Clause, or Apache-2.0 | Before committing a dependency change | +| `make check` | `build`, `lint`, `test`, and `licenses` | Before pushing — CI runs exactly this | +| `make clean` | Removes the binary, `coverage.out`, `dist/`, and the license sets a snapshot generates, through `git clean`, touching nothing else | Any time | `make check` is the whole gate: `.github/workflows/ci.yml` installs the linter, runs `make check`, and does nothing else, so a green check locally is a green CI by @@ -41,9 +42,11 @@ GOOS=windows GOARCH=amd64 go build ./cmd/tfscli time it was cut; `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2` builds it with the Go you have. CI pins v2.13 against Go 1.26. -Every other target needs Go alone. [`goreleaser`](https://goreleaser.com) v2 is needed -only to preview a release locally (see "Releasing"); the release itself installs it in -the workflow. +Every other target needs Go alone; `make licenses` installs go-licenses into a +temporary directory itself. [`goreleaser`](https://goreleaser.com) v2 is needed only to +preview a release locally (see "Releasing"); the release itself installs it in the +workflow. The preview installs go-licenses with `go install` and runs it by name, so Go's install directory — +`$(go env GOBIN)`, or `$(go env GOPATH)/bin` when that is unset — must be on `PATH`. ## Code rules @@ -205,9 +208,17 @@ What the job does, in order: `make check` on the commit it is about to tag; then `## [Unreleased]`, so that the unreleased entries become the new version and `[Unreleased]` is left empty; then `tools/release-notes.go`, which reads that section back from the rewritten changelog into a file outside the repository and prints it to the log; -then the commit `Release X.Y.Z`, the tag, and the push; then goreleaser, run directly by -its action rather than through the `Makefile`. The result is six archives — linux, -windows, darwin × amd64, arm64 — plus `checksums.txt`, attached to a draft release titled +then `goreleaser build --snapshot`, which builds the six binaries and publishes nothing, +and a check that each platform's license set was generated and that the linux/amd64 +binary prints it; then the commit `Release X.Y.Z`, the tag, and the push; then +goreleaser, run directly by its action rather than through the `Makefile`. Both +goreleaser runs first install go-licenses v2.0.1, which they find because +`actions/setup-go` puts `$(go env GOPATH)/bin` on `PATH`; each build then saves the +licenses of its target's dependencies into +`internal/licenses/embed/-/third-party/`, which the binary embeds and +`tfscli licenses` prints. The result is six archives — linux, windows, darwin × amd64, +arm64 — each unpacking into a directory of its own name and holding the binary, +`LICENSE`, and `skills/`, plus `checksums.txt`, attached to a draft release titled `tfscli vX.Y.Z`. Its body is the extracted section, passed as `--release-notes`, followed by `release.footer` from `.goreleaser.yaml`. What goes into each archive is also `.goreleaser.yaml`; that the release is a draft is `draft: true` there. @@ -222,8 +233,10 @@ field, never into a flag. The body of the next release is the `[Unreleased]` section as it stands, followed by the footer; `go run tools/release-notes.go Unreleased` prints the section. To see what the archives would contain without making a release, run `goreleaser release --snapshot ---clean`, which builds them into `dist/` and publishes nothing. A snapshot renders no -release body: goreleaser skips the changelog step entirely for it. +--clean`, which builds them into `dist/` and publishes nothing. It also leaves the +generated license sets in `internal/licenses/embed/`, which git ignores and `make clean` +removes. A snapshot renders no release body: goreleaser skips the changelog step +entirely for it. ### When it goes wrong @@ -232,7 +245,8 @@ repository exactly as it was — fix it through a pull request and run the workf - `make check` is red on the commit being released; - the version is not of the form `X.Y.Z`, or the changelog already has a section for it; -- `[Unreleased]` has no entries, so there is nothing to release. +- `[Unreleased]` has no entries, so there is nothing to release; +- the snapshot build fails, or a binary would ship without its third-party licenses. After the push the failure to expect is a release body that is wrong rather than unusable — the wrong entries under `[Unreleased]`, say. The release is a draft, so delete diff --git a/Makefile b/Makefile index 30c170a..65e6cba 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ # # Prerequisites: Go, and — for `lint` — golangci-lint v2 on PATH. -.PHONY: build lint test check clean +.PHONY: build lint test licenses check clean # --------------------------------------------------------------------------- # Build @@ -44,17 +44,26 @@ test: go tool cover -func=coverage.out go run tools/check-coverage.go 85 +# Fail when a dependency linked into the binary on any release platform is +# under a license outside the allowed list, which tools/check-licenses.go +# holds. It runs go-licenses once per platform, since the dependencies differ +# by platform, and installs it in a temporary directory, so nothing is needed +# beforehand and go.mod is left alone. +licenses: + go run tools/check-licenses.go + # Everything the project verifies, in one target. This is what CI runs. -check: build lint test +check: build lint test licenses # --------------------------------------------------------------------------- # Clean # --------------------------------------------------------------------------- # Remove every build artifact — the binary, the coverage profile, and the dist/ -# a local goreleaser snapshot leaves — leaving the working tree as git sees it. -# -X limits git clean to ignored files, -d lets it remove dist/, and the paths -# restrict it to exactly these artifacts, so a local config.json, IDE settings, -# and untracked work are never touched. +# and the third-party licenses a local goreleaser snapshot leaves — leaving the +# working tree as git sees it. -X limits git clean to ignored files, so the +# committed placeholders under internal/licenses/embed stay; -d lets it remove +# directories; and the paths restrict it to exactly these artifacts, so a local +# config.json, IDE settings, and untracked work are never touched. clean: - git clean -fdX -- tfscli tfscli.exe coverage.out dist + git clean -fdX -- tfscli tfscli.exe coverage.out dist internal/licenses/embed diff --git a/README.md b/README.md index 85fd932..0c92fe4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Download an archive from the [releases page](https://github.com/dpleshakov/tfscl | Windows | `windows-amd64.zip` | `windows-arm64.zip` | | macOS | `darwin-amd64.tar.gz` | `darwin-arm64.tar.gz` | -Every archive holds the binary together with `LICENSE`, `README.md`, `CHANGELOG.md`, `config.example.json`, and the agent skill in `skills/tfscli/SKILL.md`. SHA-256 sums for all six archives are in `checksums.txt`, attached to the same release. +Every archive unpacks into a directory of the same name, `tfscli---/`, holding the binary, `LICENSE`, and the agent skill in `skills/tfscli/SKILL.md`. SHA-256 sums for all six archives are in `checksums.txt`, attached to the same release. Verify a download before using it: @@ -70,7 +70,7 @@ Cross-compilation follows the usual Go pattern: GOOS=windows GOARCH=amd64 go build -o tfscli.exe ./cmd/tfscli ``` -A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. +A binary built this way reports its version as `dev (unknown)`: the real values are stamped in by the release build. Its `tfscli licenses` prints the licenses of tfscli and Go alone: the third-party license texts are generated and embedded by the release build as well. ## Authentication @@ -173,7 +173,7 @@ The settings come from four sources. Later sources override earlier ones: 3. Environment variables 4. Command-line flags -The config file is optional: every setting it holds, except the TLS ones, can be given by an environment variable or a flag instead. To use one, copy `config.example.json` to that location and fill in the values: +The config file is optional: every setting it holds, except the TLS ones, can be given by an environment variable or a flag instead. To use one, create it at that location; for example: ```json { @@ -211,9 +211,9 @@ The full mapping is under "API and TFS version mapping" in the [REST API referen tfscli [flags] [arguments] ``` -Commands are named after the REST API reference: the area and the resource are the segments of the operation's page path, so Get Work Item, documented under `.../wit/work-items/get-work-item`, is `wit work-items get`. `auth login` is local to tfscli and has no counterpart in the API. +Commands are named after the REST API reference: the area and the resource are the segments of the operation's page path, so Get Work Item, documented under `.../wit/work-items/get-work-item`, is `wit work-items get`. `auth login` and `licenses` are local to tfscli and have no counterpart in the API. -`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. +`tfscli --version` prints the version and the commit it was built from; `tfscli --help`, and `--help` on any command, lists the flags. `tfscli licenses` prints the components built into the binary — tfscli itself, the Go standard library, and the third-party modules — followed by the license text of each. ### `wit work-items get` diff --git a/config.example.json b/config.example.json deleted file mode 100644 index a9ec917..0000000 --- a/config.example.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "project": "MyProject" -} diff --git a/docs/architecture.md b/docs/architecture.md index 502aed5..0db7ab5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,6 +110,28 @@ This document records the technology stack, the architecture, and the project st --- +### License notices: `github.com/google/go-licenses/v2` (build tool) + +**Rationale:** +- The binary links the Go standard library and the third-party modules statically, and their licenses (MIT, BSD-3-Clause, Apache-2.0) require the license text to accompany every binary copy. The texts are embedded in the binary and printed by `tfscli licenses`, so they travel with the binary even when it is taken out of the archive. GitHub CLI does the same (`gh licenses`). +- go-licenses finds the license file of each dependency, including one kept at the root of a repository above the module, classifies it, and saves it per package path. It runs at build time only: it is never linked into the binary and does not enter `go.mod`, so it does not count against the dependency budget above. +- The dependencies differ by platform (`inconshreveable/mousetrap` is linked on Windows only), so the set is generated per target, by a build pre-hook in `.goreleaser.yaml` with the target's `GOOS` and `GOARCH`, and each binary embeds the set of its own platform. The hook runs without a shell. go-licenses is installed by a `before` hook for the platform goreleaser runs on; `go run` in the build hook would build it for the target platform instead. +- go-licenses skips the standard library, so its license is a committed copy, `internal/licenses/go.LICENSE`, which a test keeps equal to the toolchain's. +- tfscli's own license is embedded too, as `internal/licenses/tfscli.LICENSE`, which a test keeps equal to `LICENSE`. The project owes no notice to itself, but whoever passes the binary on owes one, and finds it in the binary. It is a committed copy rather than part of the generated set, so that every build carries it and it is not listed among the third-party modules; the generated set excludes tfscli with `--ignore`. +- `make check` runs `go-licenses check` with the allowed licenses for each release platform, through `tools/check-licenses.go`, so a dependency under another license fails the pull request that brings it, whichever platform links it. The program installs go-licenses for the platform it runs on before setting each target's `GOOS` and `GOARCH`, for the same reason as the `before` hook. +- The release workflow builds a snapshot before it pushes anything and checks that every platform's set was generated and that the linux/amd64 binary prints it, so that a hook that left a binary without its licenses stops the release before the tag. + +**Considered alternatives:** + +| Alternative | Why rejected | +|---|---| +| License files in the release archive | Satisfies the licenses only while the binary stays beside them; a binary copied out of the archive carries no notice. | +| One set for every platform | go-licenses cannot merge sets, so a union needs a merging program; a per-platform set needs none, and each binary carries exactly what it links. | +| A set committed to the repository | A committed copy has to be regenerated and checked for drift on every dependency change; generating it at release time cannot drift. | +| A hand-written collector over `go list -deps` and the module cache | Misses license files outside the module directory and cannot classify a license, which `go-licenses check` needs. | + +--- + ### Summary | Component | Choice | External dependency? | @@ -122,7 +144,7 @@ This document records the technology stack, the architecture, and the project st | JSON parsing | `encoding/json` | No (stdlib) | | Config format | JSON via `encoding/json` | No (stdlib) | -Total external dependencies: **3** (cobra, html-to-markdown, x/term). Minimal dependency footprint for a CLI tool. +Total external dependencies: **3** (cobra, html-to-markdown, x/term). Minimal dependency footprint for a CLI tool. go-licenses, which generates the license notices, is a build tool and not among them. --- @@ -181,6 +203,7 @@ This section describes the conceptual module structure, their responsibilities, - **htmlmd** — thin wrapper over `github.com/JohannesKaufmann/html-to-markdown/v2`. One method: `Convert(html string) (string, error)`. v1 uses library defaults only; the wrapper is the extension point where TFS-specific rules will be registered once real HTML samples have been collected (see the `html-quirks` entry in `docs/backlog.md`). - **tfserr** — typed error `Error{Category, Message, HTTPStatus, Cause}` with the stable category set from the brief. Provides `Print(err, w)` writing `Error [category]: message (HTTP status)` and `ExitCode(err)` mapping to a non-zero process exit code (v1: 1 for every category; the API leaves room for per-category exit codes later without breaking the contract). - **log** — `Logger` interface with two implementations: `noop` (default) and `stderr` (selected by `--verbose` / `TFSCLI_VERBOSE=1`). Methods: `LogRequest(method, url, status, dur)`, `Warn(msg)` for a condition worth reporting, and `Info(msg)` for what tfscli did on its own, such as sending a request without a version after a failed negotiation. The logger interface accepts only these four request fields — PAT and the `Authorization` header are never passed in, so they cannot leak through the logger by construction. +- **licenses** — the license texts of the code linked into the binary, for `tfscli licenses`. Always embeds `tfscli.LICENSE`, a copy of the project's `LICENSE`, and `go.LICENSE`, the license of the Go standard library; on each release platform also embeds `embed/-/third-party/`, which the release build fills with go-licenses and git ignores. A committed `PLACEHOLDER` beside it keeps `go:embed` compiling in every other build, whose output then says that the third-party texts are only in the release builds. Depends on nothing else in the module. ### Key dependency interfaces @@ -334,7 +357,7 @@ Go module path: `github.com/dpleshakov/tfscli`. ### Layout principle -Standard Go CLI layout: a single binary entry point under `cmd/`, all implementation under `internal/`. Package boundaries are exactly the module boundaries from the Architecture section — one Go package per module (cli, config, apiclient, workitem, wiql, htmlmd, tfserr, log) — so the dependency rules stated there are visible in import lists and enforced by the compiler rather than by convention. Everything lives under `internal/` because tfscli is a CLI tool, not a library: a zero public API surface keeps full freedom to refactor between releases. +Standard Go CLI layout: a single binary entry point under `cmd/`, all implementation under `internal/`. Package boundaries are exactly the module boundaries from the Architecture section — one Go package per module (cli, config, apiclient, workitem, wiql, htmlmd, tfserr, log, licenses) — so the dependency rules stated there are visible in import lists and enforced by the compiler rather than by convention. Everything lives under `internal/` because tfscli is a CLI tool, not a library: a zero public API surface keeps full freedom to refactor between releases. ### Top-level directories diff --git a/docs/archive/2026-10-06-tasks-third-party-licenses.md b/docs/archive/2026-10-06-tasks-third-party-licenses.md new file mode 100644 index 0000000..8f287a1 --- /dev/null +++ b/docs/archive/2026-10-06-tasks-third-party-licenses.md @@ -0,0 +1,127 @@ +# 2026-10-06-tasks-third-party-licenses.md + +**Status:** Archived + +## Context + +Goal: every tfscli binary carries the license texts its distribution requires, +and the release archive holds only what a user needs. + +The binary statically contains eight third-party modules — MIT +(`JohannesKaufmann/html-to-markdown/v2`, `JohannesKaufmann/dom`), BSD-3-Clause +(`spf13/pflag`, `golang.org/x/term`, `x/sys`, `x/net`), Apache-2.0 +(`spf13/cobra`, and `inconshreveable/mousetrap` on Windows only) — and the Go +standard library and runtime (BSD-3-Clause). Each of these licenses requires +its text, and for MIT and BSD the copyright notice, to accompany a binary +copy. No module carries a NOTICE file. Today the archives ship only the +project's own `LICENSE`. + +Decisions already made: + +- The license texts are embedded in the binary and printed by + `tfscli licenses`, so that they reach every copy of the binary, including + one taken out of the archive. This follows GitHub CLI (`cli/cli`: + `script/licenses`, `internal/licenses`, `gh licenses`). +- `tfscli licenses` takes no flags and no arguments, prints to stdout, needs + no credential and no configuration, and exits 0. Its output is a list of + components, the license of the Go standard library, and the texts of the + third-party licenses. The list is built from the directory paths of the + embedded set: `go-licenses report` writes to stdout, and a goreleaser hook + cannot redirect it without a shell. The license name is visible at the top + of each text. +- A binary not built by the release (`go build`, `go install`, `make check`) + prints the Go license and a note that the third-party texts are only in + release builds. +- The set is generated per platform, `GOOS`/`GOARCH`, by `go-licenses` v2.0.1 + (`github.com/google/go-licenses/v2`), a build tool that is never linked into + the binary and does not enter `go.mod`. Each binary embeds only the set of + its own platform. +- Generation is configured in `.goreleaser.yaml` alone, with no script: a + `before` hook runs `go install github.com/google/go-licenses/v2@v2.0.1`, and + a build pre-hook runs `go-licenses save ./cmd/tfscli` with `GOOS` and + `GOARCH` set from the target, into + `internal/licenses/embed/-/third-party`, + `--ignore github.com/dpleshakov/tfscli --force`. A goreleaser hook runs + without a shell and accepts templated `env`; a separate directory per target + keeps the parallel builds from overwriting each other. `go run …@v2.0.1` + cannot be used in the pre-hook: with the target's `GOOS` it builds + go-licenses for that platform and cannot execute it. +- The generated `third-party/` directories are ignored by git. Each + `embed/-/` holds a committed placeholder so that `go:embed` + compiles without a set; the placeholder sits outside `third-party/`, which + `--force` deletes. +- The Go license is a committed copy, `internal/licenses/go.LICENSE`, embedded + in every build; a test compares it with `$(go env GOROOT)/LICENSE`. +- `make check` gains a `licenses` target: + `go run github.com/google/go-licenses/v2@v2.0.1 check ./cmd/tfscli + --allowed_licenses=MIT,BSD-3-Clause,Apache-2.0`. `go run` needs nothing + installed beforehand, so the tools required for `make check` do not change. + The check covers the platform it runs on. +- The archive holds the binary, `LICENSE`, and `skills/`, wrapped in a + directory named like the archive (`wrap_in_directory`). `README.md`, + `CHANGELOG.md`, and `config.example.json` leave the archive; + `config.example.json` is deleted from the repository, since README shows the + same example. +- `docs/project-brief.md` does not change: the command naming rule covers + REST API operations only, and `licenses` is not one. + +--- + +### TASK-01 `licenses-package` +**Description:** Add the package `internal/licenses`: a placeholder in +`embed/-/` for each of the six release platforms (linux, windows, +darwin × amd64, arm64), one build-tagged file per platform embedding its +directory and one for every other platform embedding nothing, the committed +`go.LICENSE`, and a function assembling the output described in Context. +Ignore the generated `third-party/` directories in `.gitignore`, and make +`make clean` remove them. +**Definition of done:** Tests cover the output with a set, without a set, and +the comparison of `go.LICENSE` with the toolchain's `LICENSE`; `make check` is +green with coverage of `internal/...` at 85% or above. +**Status:** Done + +### TASK-02 `licenses-command` +**Description:** Add `tfscli licenses` to `internal/cli`, printing the output +of `internal/licenses` to stdout. +**Definition of done:** Tests show that the command prints the output, runs +without a credential or a config file, and refuses arguments; `make check` is +green. +**Status:** Done + +### TASK-03 `release-generation` +**Description:** Add the `before` hook installing go-licenses and the build +pre-hook generating the set to `.goreleaser.yaml`. +**Definition of done:** `goreleaser release --snapshot --clean` succeeds; +`tfscli licenses` of the windows/amd64 binary lists the Go standard library +and the eight third-party modules and prints their texts; `git status` is +clean after the snapshot. +**Status:** Done + +### TASK-04 `archive-contents` +**Description:** Reduce the archive files to `LICENSE` and `skills`, enable +`wrap_in_directory`, and delete `config.example.json` from the repository, +rewording its mentions in `README.md` and `.gitignore`. +**Definition of done:** The snapshot archives unpack into +`tfscli---/` holding exactly the binary, `LICENSE`, and +`skills/tfscli/SKILL.md`; no file of the repository mentions +`config.example.json` outside `docs/archive/`. +**Status:** Done + +### TASK-05 `licenses-check` +**Description:** Add the `licenses` target to the `Makefile` and include it in +`check`. +**Definition of done:** `make check` is green and runs the target; with a +license removed from `--allowed_licenses`, the target fails. +**Status:** Done + +### TASK-06 `docs` +**Description:** Document the change: `README.md` (the `licenses` command, the +archive contents and layout, installation), `CHANGELOG.md` (Added: the +command; Changed: the archive contents and layout), `docs/architecture.md` +(the package, go-licenses as a build tool), `CONTRIBUTING.md` (the `licenses` +target, go-licenses in the release and the snapshot, `$(go env GOPATH)/bin` on +`PATH` for a snapshot), `skills/tfscli/SKILL.md` (the command), and +`CLAUDE.md` where it summarises any of these. +**Definition of done:** The documents describe the new state; `make check` is +green. +**Status:** Done diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 51d6bd5..521f428 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -96,6 +96,7 @@ func newRoot(b build, stdin prompter, stdout, stderr io.Writer) *cobra.Command { root.AddCommand(newAuthCmd(g)) root.AddCommand(newWitCmd(g)) + root.AddCommand(newLicensesCmd(g)) return root } diff --git a/internal/cli/licenses.go b/internal/cli/licenses.go new file mode 100644 index 0000000..bbbb712 --- /dev/null +++ b/internal/cli/licenses.go @@ -0,0 +1,26 @@ +package cli + +import ( + "io" + + "github.com/spf13/cobra" + + "github.com/dpleshakov/tfscli/internal/licenses" +) + +// newLicensesCmd builds "tfscli licenses", a local command like auth login: it +// reads neither the credential nor the config file and calls no server. +func newLicensesCmd(g *globals) *cobra.Command { + return &cobra.Command{ + Use: "licenses", + Short: "Print the licenses of the code included in tfscli", + Long: "Print the components included in this binary — tfscli itself, the Go\n" + + "standard library, and the third-party modules — followed by the license text\n" + + "of each. The third-party texts are embedded only in the release builds.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + _, err := io.WriteString(g.stdout, licenses.Text()) + return err + }, + } +} diff --git a/internal/cli/licenses_test.go b/internal/cli/licenses_test.go new file mode 100644 index 0000000..8fb2395 --- /dev/null +++ b/internal/cli/licenses_test.go @@ -0,0 +1,54 @@ +package cli + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/dpleshakov/tfscli/internal/licenses" +) + +func TestLicensesPrintsTheLicenses(t *testing.T) { + stdout, stderr, code := execute(t, nil, "licenses") + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + if stderr != "" { + t.Errorf("stderr = %q, want nothing", stderr) + } + if stdout != licenses.Text() { + t.Errorf("stdout = %q, want the text of internal/licenses", stdout) + } +} + +// TestLicensesNeedsNoSettings runs the command with no credential and a config +// file that cannot be parsed: it reads neither. +func TestLicensesNeedsNoSettings(t *testing.T) { + isolate(t) + cfgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", cfgHome) + writeTestFile(t, filepath.Join(cfgHome, "tfscli", "config.json"), "{not json") + + var out, errOut bytes.Buffer + code := run(testBuild, []string{"licenses"}, noTerminal{}, &out, &errOut) + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, errOut.String()) + } + if !strings.HasPrefix(out.String(), "The tfscli binary contains the following components:") { + t.Errorf("stdout = %q, want the licenses", out.String()) + } +} + +func TestLicensesRefusesArguments(t *testing.T) { + stdout, stderr, code := execute(t, nil, "licenses", "extra") + + if code == 0 { + t.Fatalf("exit code = 0, want non-zero (stdout: %s)", stdout) + } + if !strings.HasPrefix(stderr, "Error [config]: ") { + t.Errorf("stderr = %q, want a config error", stderr) + } +} diff --git a/internal/licenses/doc.go b/internal/licenses/doc.go new file mode 100644 index 0000000..1b688d4 --- /dev/null +++ b/internal/licenses/doc.go @@ -0,0 +1,6 @@ +// Package licenses holds the license texts of the code linked into the +// binary: tfscli itself and the Go standard library, always, and the +// third-party modules of the platform the binary was built for, which the +// release build generates with go-licenses and embeds. Text assembles them +// for "tfscli licenses". +package licenses diff --git a/internal/licenses/embed/darwin-amd64/PLACEHOLDER b/internal/licenses/embed/darwin-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/darwin-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/darwin-arm64/PLACEHOLDER b/internal/licenses/embed/darwin-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/darwin-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/linux-amd64/PLACEHOLDER b/internal/licenses/embed/linux-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/linux-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/linux-arm64/PLACEHOLDER b/internal/licenses/embed/linux-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/linux-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/windows-amd64/PLACEHOLDER b/internal/licenses/embed/windows-amd64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/windows-amd64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed/windows-arm64/PLACEHOLDER b/internal/licenses/embed/windows-arm64/PLACEHOLDER new file mode 100644 index 0000000..502df49 --- /dev/null +++ b/internal/licenses/embed/windows-arm64/PLACEHOLDER @@ -0,0 +1,3 @@ +The release build writes the third-party licenses of this platform into +third-party/ next to this file. The file keeps the directory, and go:embed +with it, working in every other build. diff --git a/internal/licenses/embed_darwin_amd64.go b/internal/licenses/embed_darwin_amd64.go new file mode 100644 index 0000000..13f8851 --- /dev/null +++ b/internal/licenses/embed_darwin_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of darwin/amd64, written into +// embed/darwin-amd64/third-party by the release build. +// +//go:embed embed/darwin-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/darwin-amd64" diff --git a/internal/licenses/embed_darwin_arm64.go b/internal/licenses/embed_darwin_arm64.go new file mode 100644 index 0000000..fe10a10 --- /dev/null +++ b/internal/licenses/embed_darwin_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of darwin/arm64, written into +// embed/darwin-arm64/third-party by the release build. +// +//go:embed embed/darwin-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/darwin-arm64" diff --git a/internal/licenses/embed_linux_amd64.go b/internal/licenses/embed_linux_amd64.go new file mode 100644 index 0000000..058644a --- /dev/null +++ b/internal/licenses/embed_linux_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of linux/amd64, written into +// embed/linux-amd64/third-party by the release build. +// +//go:embed embed/linux-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/linux-amd64" diff --git a/internal/licenses/embed_linux_arm64.go b/internal/licenses/embed_linux_arm64.go new file mode 100644 index 0000000..af94298 --- /dev/null +++ b/internal/licenses/embed_linux_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of linux/arm64, written into +// embed/linux-arm64/third-party by the release build. +// +//go:embed embed/linux-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/linux-arm64" diff --git a/internal/licenses/embed_other.go b/internal/licenses/embed_other.go new file mode 100644 index 0000000..39bd1a0 --- /dev/null +++ b/internal/licenses/embed_other.go @@ -0,0 +1,12 @@ +//go:build !((linux || windows || darwin) && (amd64 || arm64)) + +package licenses + +import "embed" + +// platformFS is empty on a platform the release does not build: no set of +// third-party licenses is ever generated for it. +var platformFS embed.FS + +// platformDir names no directory of the empty platformFS. +const platformDir = "embed/other" diff --git a/internal/licenses/embed_windows_amd64.go b/internal/licenses/embed_windows_amd64.go new file mode 100644 index 0000000..1b2a464 --- /dev/null +++ b/internal/licenses/embed_windows_amd64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of windows/amd64, written into +// embed/windows-amd64/third-party by the release build. +// +//go:embed embed/windows-amd64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/windows-amd64" diff --git a/internal/licenses/embed_windows_arm64.go b/internal/licenses/embed_windows_arm64.go new file mode 100644 index 0000000..85e9a8f --- /dev/null +++ b/internal/licenses/embed_windows_arm64.go @@ -0,0 +1,12 @@ +package licenses + +import "embed" + +// platformFS holds the third-party licenses of windows/arm64, written into +// embed/windows-arm64/third-party by the release build. +// +//go:embed embed/windows-arm64 +var platformFS embed.FS + +// platformDir is the directory of platformFS that holds the set. +const platformDir = "embed/windows-arm64" diff --git a/internal/licenses/go.LICENSE b/internal/licenses/go.LICENSE new file mode 100644 index 0000000..2a7cf70 --- /dev/null +++ b/internal/licenses/go.LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/licenses/licenses.go b/internal/licenses/licenses.go new file mode 100644 index 0000000..d98ed58 --- /dev/null +++ b/internal/licenses/licenses.go @@ -0,0 +1,132 @@ +package licenses + +import ( + _ "embed" // for go:embed of the tfscli and Go licenses + "io/fs" + "path" + "sort" + "strings" +) + +// ownLicense is the license of tfscli itself, a copy of LICENSE at the root of +// the repository. +// +//go:embed tfscli.LICENSE +var ownLicense string + +// goLicense is the license of the Go standard library and runtime, a copy of +// LICENSE from the Go distribution. +// +//go:embed go.LICENSE +var goLicense string + +// separator frames the name of each component above its license texts. +var separator = strings.Repeat("=", 80) + +// Text returns the license information of this binary: the list of the +// components it includes, followed by the license texts of each. +func Text() string { + set, err := fs.Sub(platformFS, path.Join(platformDir, "third-party")) + if err != nil { + set = nil + } + return text(embedded(), set) +} + +// embedded lists the components whose licenses every build carries, in the +// order they are printed. +func embedded() []license { + return []license{ + {name: "tfscli", text: ownLicense}, + {name: "Go standard library and runtime", text: goLicense}, + } +} + +// license is a component whose license text is embedded in every build. +type license struct { + name string + text string +} + +// component is one entry of the output: a name and its license files. +type component struct { + name string + files []string +} + +// text assembles the output from fixed, the components every build carries, +// and set, the third-party licenses saved by go-licenses: one directory per +// package path, holding the license files of its module. A nil or empty set +// means a build the release did not make. +func text(fixed []license, set fs.FS) string { + third := thirdParty(set) + + var b strings.Builder + b.WriteString("The tfscli binary contains the following components:\n\n") + for _, l := range fixed { + b.WriteString(" " + l.name + "\n") + } + for _, c := range third { + b.WriteString(" " + c.name + "\n") + } + if len(third) == 0 { + b.WriteString("\nThis build does not carry the licenses of the third-party modules it\n" + + "includes: they are embedded only in the release builds, available at\n" + + "https://github.com/dpleshakov/tfscli/releases.\n") + } + + for _, l := range fixed { + writeComponent(&b, l.name, l.text) + } + for _, c := range third { + var texts []string + for _, f := range c.files { + data, err := fs.ReadFile(set, f) + if err != nil { + continue + } + texts = append(texts, string(data)) + } + writeComponent(&b, c.name, strings.Join(texts, "\n")) + } + return b.String() +} + +// thirdParty lists the components of set, sorted by name, with their files. +// A set that cannot be read yields none. +func thirdParty(set fs.FS) []component { + if set == nil { + return nil + } + byDir := map[string]*component{} + err := fs.WalkDir(set, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + dir := path.Dir(p) + if byDir[dir] == nil { + byDir[dir] = &component{name: dir} + } + byDir[dir].files = append(byDir[dir].files, p) + return nil + }) + if err != nil { + return nil + } + + list := make([]component, 0, len(byDir)) + for _, c := range byDir { + list = append(list, *c) + } + sort.Slice(list, func(i, j int) bool { return list[i].name < list[j].name }) + return list +} + +// writeComponent appends the framed name of a component and its license text. +func writeComponent(b *strings.Builder, name, license string) { + b.WriteString("\n" + separator + "\n" + name + "\n" + separator + "\n\n") + b.WriteString(strings.TrimRight(license, "\n") + "\n") +} diff --git a/internal/licenses/licenses_test.go b/internal/licenses/licenses_test.go new file mode 100644 index 0000000..b274398 --- /dev/null +++ b/internal/licenses/licenses_test.go @@ -0,0 +1,147 @@ +package licenses + +import ( + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +// fixed stands in for the licenses every build carries. +var fixed = []license{ + {name: "tfscli", text: "OWN LICENSE\n"}, + {name: "Go standard library and runtime", text: "GO LICENSE\n"}, +} + +func TestTextWithSet(t *testing.T) { + set := fstest.MapFS{ + "github.com/spf13/cobra/LICENSE.txt": {Data: []byte("Apache License\n")}, + "golang.org/x/term/LICENSE": {Data: []byte("Copyright 2009 The Go Authors.\n")}, + "github.com/example/notice/LICENSE": {Data: []byte("MIT License\n")}, + "github.com/example/notice/NOTICE": {Data: []byte("Notice text\n")}, + "github.com/JohannesKaufmann/dom/LICENSE": {Data: []byte("MIT License\n\nCopyright (c) 2024\n")}, + "github.com/JohannesKaufmann/dom/sub/LICENSE": {Data: []byte("Nested\n")}, + "github.com/JohannesKaufmann/dom/LICENSE.more": {Data: []byte("More\n")}, + } + + got := text(fixed, set) + + wantList := "The tfscli binary contains the following components:\n\n" + + " tfscli\n" + + " Go standard library and runtime\n" + + " github.com/JohannesKaufmann/dom\n" + + " github.com/JohannesKaufmann/dom/sub\n" + + " github.com/example/notice\n" + + " github.com/spf13/cobra\n" + + " golang.org/x/term\n" + if !strings.HasPrefix(got, wantList) { + t.Errorf("text() does not start with the component list:\n%s", got) + } + if strings.Contains(got, "only in the release builds") { + t.Errorf("text() with a set says the set is missing:\n%s", got) + } + + framed := func(name, body string) string { + return "\n" + separator + "\n" + name + "\n" + separator + "\n\n" + body + } + for _, want := range []string{ + framed("tfscli", "OWN LICENSE\n"), + framed("Go standard library and runtime", "GO LICENSE\n"), + framed("github.com/spf13/cobra", "Apache License\n"), + framed("github.com/example/notice", "MIT License\n\nNotice text\n"), + framed("github.com/JohannesKaufmann/dom", "MIT License\n\nCopyright (c) 2024\n\nMore\n"), + } { + if !strings.Contains(got, want) { + t.Errorf("text() lacks %q:\n%s", want, got) + } + } + own := strings.Index(got, framed("tfscli", "")) + goLic := strings.Index(got, framed("Go standard library and runtime", "")) + dom := strings.Index(got, framed("github.com/JohannesKaufmann/dom", "")) + if own > goLic || goLic > dom { + t.Errorf("text() does not put tfscli, then Go, before the third-party licenses:\n%s", got) + } +} + +func TestTextWithoutSet(t *testing.T) { + for name, set := range map[string]fs.FS{ + "nil": nil, + "empty": fstest.MapFS{}, + "missing": sub(t, fstest.MapFS{"PLACEHOLDER": {}}, "third-party"), + } { + t.Run(name, func(t *testing.T) { + got := text(fixed, set) + want := "The tfscli binary contains the following components:\n\n" + + " tfscli\n" + + " Go standard library and runtime\n\n" + + "This build does not carry the licenses of the third-party modules it\n" + + "includes: they are embedded only in the release builds, available at\n" + + "https://github.com/dpleshakov/tfscli/releases.\n" + + "\n" + separator + "\ntfscli\n" + separator + "\n\n" + + "OWN LICENSE\n" + + "\n" + separator + "\nGo standard library and runtime\n" + separator + "\n\n" + + "GO LICENSE\n" + if got != want { + t.Errorf("text() =\n%s\nwant\n%s", got, want) + } + }) + } +} + +// sub is fs.Sub that fails the test on an error, which only an invalid dir +// can cause. +func sub(t *testing.T, fsys fs.FS, dir string) fs.FS { + t.Helper() + s, err := fs.Sub(fsys, dir) + if err != nil { + t.Fatalf("fs.Sub(%q): %v", dir, err) + } + return s +} + +// TestTextOfThisBuild covers Text over the embedded set of the platform the +// tests run on. The set is usually absent, but a local goreleaser snapshot +// leaves one behind until make clean, so the test holds either way. +func TestTextOfThisBuild(t *testing.T) { + got := Text() + if want := text(embedded(), sub(t, platformFS, platformDir+"/third-party")); got != want { + t.Errorf("Text() =\n%s\nwant\n%s", got, want) + } + for name, lic := range map[string]string{"tfscli": ownLicense, "Go": goLicense} { + if !strings.Contains(got, lic) { + t.Errorf("Text() lacks the %s license:\n%s", name, got) + } + } +} + +// TestGoLicenseMatchesToolchain keeps the committed copy of the Go license +// equal to the one of the toolchain that builds tfscli. +func TestGoLicenseMatchesToolchain(t *testing.T) { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + t.Fatalf("go env GOROOT: %v", err) + } + want, err := os.ReadFile(filepath.Join(strings.TrimSpace(string(out)), "LICENSE")) + if err != nil { + t.Fatalf("reading the toolchain license: %v", err) + } + if goLicense != string(want) { + t.Errorf("internal/licenses/go.LICENSE differs from LICENSE of the Go toolchain; " + + "copy $(go env GOROOT)/LICENSE over it") + } +} + +// TestOwnLicenseMatchesTheRepository keeps the embedded copy of the tfscli +// license equal to LICENSE at the root of the repository. +func TestOwnLicenseMatchesTheRepository(t *testing.T) { + want, err := os.ReadFile(filepath.Join("..", "..", "LICENSE")) + if err != nil { + t.Fatalf("reading LICENSE: %v", err) + } + if ownLicense != string(want) { + t.Errorf("internal/licenses/tfscli.LICENSE differs from LICENSE; copy LICENSE over it") + } +} diff --git a/internal/licenses/tfscli.LICENSE b/internal/licenses/tfscli.LICENSE new file mode 100644 index 0000000..f7c66ad --- /dev/null +++ b/internal/licenses/tfscli.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dmitry Pleshakov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/tfscli/SKILL.md b/skills/tfscli/SKILL.md index a8af938..6c875d8 100644 --- a/skills/tfscli/SKILL.md +++ b/skills/tfscli/SKILL.md @@ -37,9 +37,11 @@ name. Output is markdown on stdout; errors are one line on stderr. ## When not to use it -Apart from `auth login`, which is the user's to run, the tool covers four -commands: `wit work-items get`, `wit work-items list`, -`wit work-items get-batch`, and `wit wiql query-by-wiql`. Do not attempt anything below; none of it exists, +Apart from `auth login`, which is the user's to run, and `licenses`, which +prints the license texts of the code built into the binary and reads nothing +from the server, the tool covers four commands: `wit work-items get`, +`wit work-items list`, `wit work-items get-batch`, and +`wit wiql query-by-wiql`. Do not attempt anything below; none of it exists, and inventing a flag or a subcommand produces an error, not a result. - **No writes.** Nothing creates, updates, or comments on a work item. If the diff --git a/tools/check-licenses.go b/tools/check-licenses.go new file mode 100644 index 0000000..a6d4de9 --- /dev/null +++ b/tools/check-licenses.go @@ -0,0 +1,106 @@ +//go:build ignore + +// Command check-licenses fails the build when a dependency linked into tfscli +// on any release platform is under a license outside the allowed list. It runs +// `go-licenses check` once per platform, because the dependencies differ by +// platform: inconshreveable/mousetrap is linked on Windows only. +// +// go-licenses is installed into a temporary directory for the platform this +// program runs on, and only then run with each platform's GOOS and GOARCH. +// `go run` with those variables set would build go-licenses for the target +// platform, which this one cannot execute; setting them in a make recipe +// would need a Unix shell. +// +// Usage: +// +// go run tools/check-licenses.go +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// goLicenses is the go-licenses release used here; .goreleaser.yaml installs +// the same one to generate the license sets, and the two change together. +const goLicenses = "github.com/google/go-licenses/v2@v2.0.1" + +// allowed lists the licenses a dependency may be under. Each allows a binary +// distribution that carries its text, which tfscli licenses does; adding one +// is a decision, not an edit of the list. +const allowed = "MIT,BSD-3-Clause,Apache-2.0" + +// platforms are the release targets, as builds in .goreleaser.yaml lists them. +var platforms = []string{ + "linux/amd64", "linux/arm64", + "windows/amd64", "windows/arm64", + "darwin/amd64", "darwin/arm64", +} + +func main() { + os.Exit(run()) +} + +func run() int { + bin, err := os.MkdirTemp("", "check-licenses") + if err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: %v\n", err) + return 1 + } + defer os.RemoveAll(bin) + + install := exec.Command("go", "install", goLicenses) + install.Env = withEnv("GOBIN="+bin, "GOOS=", "GOARCH=") + if out, err := install.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: go install %s: %v\n%s", goLicenses, err, out) + return 1 + } + tool := filepath.Join(bin, "go-licenses") + if runtime.GOOS == "windows" { + tool += ".exe" + } + + failed := 0 + for _, p := range platforms { + goos, goarch, _ := strings.Cut(p, "/") + check := exec.Command(tool, "check", "./cmd/tfscli", "--allowed_licenses="+allowed) + check.Env = withEnv("GOOS="+goos, "GOARCH="+goarch) + // go-licenses logs warnings about assembly files it cannot inspect on + // every run; they are shown only when the check fails. + if out, err := check.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "check-licenses: %s: %v\n%s", p, err, out) + failed++ + continue + } + fmt.Printf("check-licenses: %s: every license is allowed\n", p) + } + if failed > 0 { + fmt.Fprintf(os.Stderr, "check-licenses: the check failed on %d of %d platforms; allowed: %s\n", failed, len(platforms), allowed) + return 1 + } + return 0 +} + +// withEnv returns the environment of this process with the given NAME=value +// pairs replacing any variables of the same names. +func withEnv(pairs ...string) []string { + env := make([]string, 0, len(os.Environ())+len(pairs)) + for _, kv := range os.Environ() { + name, _, _ := strings.Cut(kv, "=") + replaced := false + for _, p := range pairs { + if pn, _, _ := strings.Cut(p, "="); strings.EqualFold(pn, name) { + replaced = true + break + } + } + if !replaced { + env = append(env, kv) + } + } + return append(env, pairs...) +}