diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1687e84..7438136 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,9 +1,9 @@ { "name": "linear-cli", - "version": "0.2.10", + "version": "0.3.0", "description": "Linear CLI integration for Claude Code - manage issues, teams, and projects", "author": { "name": "0xbigboss" }, - "repository": "https://github.com/0xbigboss/linear-cli" + "repository": "https://github.com/alleneubank/linear-cli" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf53383 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +# CI: the offline verifier every push has to clear. +# +# Deliberately not running ziglint — it is a `zig build lint` step that shells +# out to a binary this workflow would have to build from source on every run, +# and it is advisory. `zig fmt --check` and the unit suite are the floors. +# +# The online suite (`zig build online`) is not here on purpose: it needs a real +# LINEAR_API_KEY and mutates a live workspace. +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # build.zig derives --version from `git describe --tags`; a shallow + # clone makes it report a bare hash. + fetch-depth: 0 + + - uses: mlugg/setup-zig@v2 + with: + # Keep in lockstep with the release workflow and README. + version: 0.16.0 + + - name: Check formatting + run: zig fmt --check build.zig src + + - name: Unit tests + run: zig build test + + - name: Version manifests agree + run: ./scripts/check-versions.sh + + cross-build: + # The npm dist targets are cross-compiled and never exercised by `zig build + # test`, so a target-specific break (libc, tcsetattr, file modes) only shows + # up at release time. Build them on every push instead. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - run: zig build npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f19f34..e58c9a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,27 +1,108 @@ +# Release: cut on a pushed v* tag. +# +# Two distribution channels, one tag: +# 1. npm — @0xbigboss/linear-cli plus four platform packages. +# 2. GitHub release assets — per-platform tarballs consumed by mise's +# `github:` backend (dotfiles config/.config/mise/config.toml). +# +# Asset naming mirrors zmx and alleneubank/agent-statusline +# (`linear---.tar.gz` + `.sha256`), which is the shape mise +# already resolves per platform. Do not add an asset_pattern on the consuming +# side: mise's per-arch matching handles this layout, and a pattern REPLACES +# that matching rather than narrowing it. +# +# Linux assets are musl-static so they run on any distro; the npm packages keep +# their gnu builds (npm installs land on a host with a libc that matches). +# +# No code signing: Zig ad-hoc signs macOS binaries at link time, and mise +# downloads carry no quarantine xattr, so Gatekeeper never engages. name: Release on: push: tags: ["v*"] +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # build.zig derives --version from `git describe --tags`. Without the + # tag history the published binary reports a bare commit hash. + fetch-depth: 0 + - uses: mlugg/setup-zig@v2 with: version: 0.16.0 + + # Fail before publishing rather than after. The tag names the npm + # packages and the release assets; a manifest that disagrees ships a + # release whose parts describe different versions. + - name: Check version consistency + run: ./scripts/check-versions.sh --expect "${GITHUB_REF_NAME#v}" + - run: zig build npm + - name: Copy binaries to npm packages run: | for p in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do cp zig-out/npm/linear-cli-${p}/linear npm/linear-cli-${p}/ done + + - name: Build GitHub release tarballs + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + mkdir -p dist + # asset label -> zig target triple + for pair in \ + "macos-aarch64 aarch64-macos" \ + "macos-x86_64 x86_64-macos" \ + "linux-aarch64 aarch64-linux-musl" \ + "linux-x86_64 x86_64-linux-musl"; do + label="${pair%% *}" + target="${pair##* }" + zig build -Doptimize=ReleaseSafe -Dtarget="$target" + asset="linear-${version}-${label}.tar.gz" + tar -C zig-out/bin -czf "dist/$asset" linear + (cd dist && sha256sum "$asset" > "$asset.sha256") + done + ls -l dist + - uses: actions/upload-artifact@v4 with: name: npm-packages path: npm/ + - uses: actions/upload-artifact@v4 + with: + name: release-assets + path: dist/ + + github-release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: release-assets + path: dist + - name: Create release with assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Create the release for this tag, or attach assets if it already + # exists (e.g. a hand-drafted release). + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes dist/* \ + || gh release upload "$GITHUB_REF_NAME" dist/* --clobber + publish: needs: build runs-on: ubuntu-latest @@ -32,6 +113,10 @@ jobs: with: name: npm-packages path: npm + - name: Restore executable bits + # upload-artifact does not preserve the mode; the packaged binary must + # stay executable or the npm wrapper spawns a non-executable file. + run: chmod +x npm/linear-cli-*/linear - env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/publish-npm.sh ${GITHUB_REF_NAME#v} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c28292 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,90 @@ +# Changelog + +Notable changes per release. Versions before 0.3.0 are recorded in the +[GitHub releases](https://github.com/alleneubank/linear-cli/releases). + +## 0.3.0 + +The first release since v0.2.11, covering the Zig 0.16 migration, a +credential-handling security audit, and a large expansion of the command +surface. + +### Breaking + +- **`--api-key` removed.** It put the secret on argv, where it is visible in + `ps` and saved to shell history. Use piped stdin (`echo "$KEY" | linear auth + set`), the no-echo prompt, `LINEAR_API_KEY`, or a credential helper. +- **`auth show` redacts by default.** It previously printed the full key to + stdout with `--redacted` as opt-in. `--reveal` is now required, and is + refused when stdout is not a TTY so the key cannot be piped into a log. +- **`linear gql` requires `--yes` for mutations.** Detection scans top-level + tokens only, so comments, string literals, and a field named `mutation` do + not trip it. Adds `--dry-run`. +- **`--endpoint` is allowlisted** to `https` on `api.linear.app` unless + `LINEAR_ALLOW_INSECURE_ENDPOINT=1`. It previously accepted plain `http://` + to any host, making it a key-exfiltration channel. +- **`issue view` no longer downloads attachments by default.** It defaulted + `attachment_dir` to `/tmp`; downloads are now opt-in via `--attachment-dir` + and written 0600. +- **`issue delete --reason` removed.** `issueDelete` takes only + `(id, permanentlyDelete)`; the value never reached the API. +- **`search --fields` now selects printed columns**, matching every other list + command. The old meaning (which fields to search) moved to `--search-fields`. +- **No `auth migrate`.** Scrubbing a key from disk cannot beat APFS + copy-on-write, snapshots, or backups, so a migrated key has to be rotated + anyway. Delete `~/.config/linear/config.json` and set up fresh instead. +- A config file of exactly 64 KiB now errors (Zig 0.16 `.limited(n)` semantics). + +### Security + +- Env-derived keys are no longer written to disk. `auth set` fell back to the + env key when none was supplied, and the persist gate then passed. The same + ordering bug silently deleted a *stored* key from disk on any later `save()` + whenever `LINEAR_API_KEY` was set. +- Config file TOCTOU: created 0644 then `chmod`'d: now created 0600 atomically + inside a 0700 directory. +- API keys are charset/length validated (`[A-Za-z0-9_-]`, 4-512) at every + ingestion point, closing CRLF header injection via a tampered config. +- `redactKey` returned the whole secret for short keys; returns a constant + below a minimum length. +- A failed `disableEcho` now aborts instead of continuing to read with echo on. + +### Added + +- **Credential provider chain**: `LINEAR_API_KEY` -> `credential_helper` -> + macOS keychain -> config file (deprecated, warns). `credential_helper` runs + an argv array (never a shell) whose stdout is the key, covering 1Password, + `pass`, `gopass`, `secret-tool`, and Vault with no platform-specific code. +- `auth status` (offline backend report) and `auth set --to keychain|file`. +- New commands: `labels list`, `users list`, `states list`, `milestone + list|view|create|update|delete`, `issue comment list|update|delete`, + `issue start`, `issue pr`, `issue id|url|title|describe`. +- Real cursor pagination on every list command: `--limit` page size, + `--pages N`/`--all`, `--cursor`, `--max-items`, with a stderr page summary. + `gql --paginate` is the generic equivalent for arbitrary documents. +- `issues list --sort FIELD[:asc|desc]` over the full `IssueSortInput` field + set, plus `--sort-nulls first|last`. +- `--bulk`/`--bulk-file`/`--bulk-stdin` on delete commands; execution is serial. +- `--description-file`/`--body-file`/`--content-file` companions for long-form + text, where `-` means stdin. +- Git integration: branch-name issue inference, using Linear's own + `Issue.branchName` rather than local slugification. +- pi package manifest exposing the `linear` skill (`pi install + git:git@github.com:alleneubank/linear-cli.git`). + +### Changed + +- **Toolchain: Zig 0.15.2 -> 0.16.0.** 0.15.2 cannot link on macOS 26 - its + bundled `libSystem.tbd` carries no symbol availability for macOS 26, so every + libc symbol resolves as undefined. 0.16's explicit `std.Io` now threads + through every command `Context`, `Config`, and `GraphqlClient`. +- Retry backoff is cancelable, so `error.Canceled` propagates out of `send()` + instead of being swallowed. + +### Release engineering + +- GitHub releases now carry per-platform tarballs + (`linear---.tar.gz` + `.sha256`) for macOS and Linux on + both architectures, installable via mise's `github:` backend. +- CI runs `zig fmt --check`, the unit suite, the cross-compile targets, and a + version-manifest consistency gate (`scripts/check-versions.sh`) on every push. diff --git a/CLAUDE.md b/CLAUDE.md index 746a40a..1b18a87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,4 +35,5 @@ - Validation that does not gate the write is worse than none: it implies a check happened while the exit status says success. `config set default_team_id` refuses to persist a team the workspace does not have, and keeps that verdict distinct from a lookup that never completed (`could not verify team` vs `not found in workspace`) so a timeout is never reported as a missing team. Same rule anywhere else validation is added. - Flags must reach the API or not exist. `issue delete --reason` was removed rather than deprecated because `issueDelete` takes only `(id, permanentlyDelete)` — the value was echoed into this CLI's own output and read like an audit trail in Linear. Do not add a flag whose only effect is on local output while naming something the API owns. - GraphQL client already handles HTTP status + GraphQL errors; retries only for 5xx with small backoff. Preserve explicit error messaging. +- Releases are cut by pushing a `v*` tag. `build.zig` derives `--version` from `git describe --tags`, so the tag is the only source of truth for the binary; the four hand-maintained manifests (`package.json`, `.claude-plugin/plugin.json`, `npm/*/package.json` including its `optionalDependencies` pins) must agree with it, and `scripts/check-versions.sh` is the gate that proves it — it runs in CI on every push and again in the release workflow *before* anything is published. Bump all four together or the gate fails closed. One tag drives two channels: npm (`scripts/publish-npm.sh`) and GitHub release assets. The assets are `linear---.tar.gz` + `.sha256`, macOS/Linux on both arches, Linux statically linked against musl; the naming mirrors zmx and `alleneubank/agent-statusline` because that is the shape mise's `github:` backend resolves per platform. Do not add an `asset_pattern` on the consuming side — it replaces mise's per-arch matching instead of narrowing it, collapsing every platform onto whichever asset sorts first. - Tests: offline unit coverage exists for config, flag parsing, printer; keep them passing. Online tests are gated by env and should remain optional. diff --git a/README.md b/README.md index 82ee1da..dbf9be4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,44 @@ Single-binary Linear client built with Zig 0.16.0. Uses stdlib only, defaults to human-readable tables with a `--json` override, and stores auth securely at `~/.config/linear/config.json` (0600). Use `linear help ` to see command-specific flags and examples. +## Install + +One owner per machine: the npm wrapper, mise, and a hand-placed binary all put +`linear` on PATH and will shadow each other. + +**npm** — any platform with node: +```bash +npm install -g @0xbigboss/linear-cli +``` + +**mise** — tracks the GitHub release assets, so every host on a fleet resolves +the same pinned build: +```toml +# ~/.config/mise/config.toml +[tools] +"github:alleneubank/linear-cli" = { version = "latest", exe = "linear" } +``` +Then `mise install`. `exe` is required: the repo is `linear-cli`, the binary is +`linear`. + +**Direct download** — each release carries +`linear---.tar.gz` plus a `.sha256` sidecar for macOS +(aarch64, x86_64) and Linux (aarch64, x86_64; statically linked against musl, +so any distro works). The tarball holds one root `linear` executable. + +**From source** — Zig 0.16.0, stdlib only: +```bash +zig build -Doptimize=ReleaseSafe # -> zig-out/bin/linear +``` + +Then configure a key (see [Config & Auth](#config--auth)) and check it: +```bash +linear config set credential_helper "op read op:////" +linear auth test +``` + ## Build & Test -- Build: `zig build -Drelease-safe` (debug is default). Binary installs to `zig-out/bin/linear`. +- Build: `zig build -Doptimize=ReleaseSafe` (debug is default). Binary installs to `zig-out/bin/linear`. - Tests: `zig build test`. Online suite runs with `LINEAR_ONLINE_TESTS=1`: `LINEAR_ONLINE_TESTS=1 LINEAR_TEST_TEAM_ID= zig build online` (requires `LINEAR_API_KEY`; optional `LINEAR_TEST_ISSUE_ID`, `LINEAR_TEST_PROJECT_ID`, `LINEAR_TEST_MILESTONE_ID`; opt-in mutations with `LINEAR_TEST_ALLOW_MUTATIONS=1`). ## Manual QA (Live API) @@ -238,7 +274,7 @@ linear auth set # configure your API key **1. Add the marketplace:** ``` -/plugin marketplace add https://github.com/0xbigboss/linear-cli +/plugin marketplace add https://github.com/alleneubank/linear-cli ``` **2. Install the plugin:** diff --git a/build.zig b/build.zig index f724f0e..6e25ba1 100644 --- a/build.zig +++ b/build.zig @@ -33,6 +33,13 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/tests/main.zig"), .target = target, .optimize = optimize, + // The env-mutation tests call libc `setenv`/`unsetenv` and rebuild + // their view of the environment from `std.c.environ`, because 0.16 + // hands `main` a snapshot of `envp` that goes stale the moment libc + // reallocates `environ`. macOS links libSystem implicitly, so this + // only ever failed on Linux — which is why it survived until CI ran + // there. The exe itself stays libc-free. + .link_libc = true, }), }); tests.root_module.addOptions("build_options", build_options); diff --git a/npm/linear-cli-darwin-arm64/package.json b/npm/linear-cli-darwin-arm64/package.json index 07f54a5..f2fafd3 100644 --- a/npm/linear-cli-darwin-arm64/package.json +++ b/npm/linear-cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@0xbigboss/linear-cli-darwin-arm64", - "version": "0.2.11", + "version": "0.3.0", "os": [ "darwin" ], diff --git a/npm/linear-cli-darwin-x64/package.json b/npm/linear-cli-darwin-x64/package.json index 5398829..1e98d52 100644 --- a/npm/linear-cli-darwin-x64/package.json +++ b/npm/linear-cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@0xbigboss/linear-cli-darwin-x64", - "version": "0.2.11", + "version": "0.3.0", "os": [ "darwin" ], diff --git a/npm/linear-cli-linux-arm64/package.json b/npm/linear-cli-linux-arm64/package.json index cd61036..c9b3a28 100644 --- a/npm/linear-cli-linux-arm64/package.json +++ b/npm/linear-cli-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@0xbigboss/linear-cli-linux-arm64", - "version": "0.2.11", + "version": "0.3.0", "os": [ "linux" ], diff --git a/npm/linear-cli-linux-x64/package.json b/npm/linear-cli-linux-x64/package.json index 89db15d..2339f25 100644 --- a/npm/linear-cli-linux-x64/package.json +++ b/npm/linear-cli-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@0xbigboss/linear-cli-linux-x64", - "version": "0.2.11", + "version": "0.3.0", "os": [ "linux" ], diff --git a/npm/linear-cli/package.json b/npm/linear-cli/package.json index 1ab777c..10b2303 100644 --- a/npm/linear-cli/package.json +++ b/npm/linear-cli/package.json @@ -1,19 +1,19 @@ { "name": "@0xbigboss/linear-cli", - "version": "0.2.11", + "version": "0.3.0", "description": "Linear CLI built with Zig", "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/0xbigboss/linear-cli.git" + "url": "git+https://github.com/alleneubank/linear-cli.git" }, "bin": { "linear": "bin.js" }, "optionalDependencies": { - "@0xbigboss/linear-cli-darwin-arm64": "0.2.11", - "@0xbigboss/linear-cli-darwin-x64": "0.2.11", - "@0xbigboss/linear-cli-linux-x64": "0.2.11", - "@0xbigboss/linear-cli-linux-arm64": "0.2.11" + "@0xbigboss/linear-cli-darwin-arm64": "0.3.0", + "@0xbigboss/linear-cli-darwin-x64": "0.3.0", + "@0xbigboss/linear-cli-linux-x64": "0.3.0", + "@0xbigboss/linear-cli-linux-arm64": "0.3.0" } } diff --git a/package.json b/package.json index 94e313f..a6deed7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linear-cli", - "version": "0.2.10", + "version": "0.3.0", "description": "Pi package for @0xbigboss/linear-cli: Linear issue/team/project management via the linear CLI. Exposes the linear skill.", "license": "MIT", "keywords": [ diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh new file mode 100755 index 0000000..9de5194 --- /dev/null +++ b/scripts/check-versions.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Version consistency gate. +# +# The git tag is the single source of truth: build.zig derives the binary's +# --version string from `git describe --tags`, so nothing in-tree can restate +# it. What CAN drift are the four hand-maintained manifests, and a release +# whose parts describe different versions is worse than one that fails to +# build — it ships silently. +# +# Checked: +# package.json pi package +# .claude-plugin/plugin.json Claude Code plugin +# npm/*/package.json npm dist packages +# npm/linear-cli optionalDependencies pins +# +# Usage: check-versions.sh [--expect ] +# --expect pins every manifest to that version (CI passes the tag). +# Without it, the manifests only have to agree with each other. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +expect="" +if [[ "${1:-}" == "--expect" ]]; then + expect="${2:?--expect needs a version}" +elif [[ $# -gt 0 ]]; then + echo "usage: check-versions.sh [--expect ]" >&2 + exit 2 +fi + +fail=0 +report() { echo " $1" >&2; fail=1; } + +declare -a files=(package.json .claude-plugin/plugin.json) +while IFS= read -r f; do files+=("$f"); done < <(ls npm/*/package.json) + +reference="$expect" +echo "Version check:" +for f in "${files[@]}"; do + v="$(jq -r '.version // empty' "$f")" + if [[ -z "$v" ]]; then + report "$f: no .version field" + continue + fi + echo " $f: $v" + if [[ -z "$reference" ]]; then + reference="$v" + elif [[ "$v" != "$reference" ]]; then + report "$f: $v != $reference" + fi +done + +# The npm wrapper pins its platform packages exactly; a stale pin resolves to +# an older binary than the wrapper claims to be. +while IFS= read -r pin; do + dep="${pin%%=*}" + v="${pin##*=}" + if [[ "$v" != "$reference" ]]; then + report "npm/linear-cli optionalDependencies['$dep']: $v != $reference" + fi +done < <(jq -r '.optionalDependencies // {} | to_entries[] | "\(.key)=\(.value)"' npm/linear-cli/package.json) + +if [[ $fail -ne 0 ]]; then + echo "FAIL: manifests disagree${expect:+ with tag $expect}" >&2 + exit 1 +fi + +echo "OK: all manifests at $reference" diff --git a/src/commands/auth.zig b/src/commands/auth.zig index 7ab7696..74fdf46 100644 --- a/src/commands/auth.zig +++ b/src/commands/auth.zig @@ -6,9 +6,6 @@ const common = @import("common"); const credentials = @import("credentials"); const process = @import("process"); const builtin = @import("builtin"); -const c = @cImport({ - @cInclude("termios.h"); -}); const Allocator = std.mem.Allocator;