Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .depot/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ jobs:
git diff --exit-code go.mod go.sum
- name: go vet
run: go vet ./...
- name: Vulnerability scan (all release targets)
run: |
set -eu
go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
scanner="$(go env GOPATH)/bin/govulncheck"
for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
echo "== $platform =="
GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./...
done
- name: Tests (race detector)
Comment thread
cursor[bot] marked this conversation as resolved.
run: go test -race ./...
- name: Build
Expand Down Expand Up @@ -76,6 +85,27 @@ jobs:
run: |
sudo apt-get update -q && sudo apt-get install -y -q shellcheck
shellcheck site/install.sh scripts/package.sh
- name: Reject unsafe release versions
run: |
set -eu
marker="/tmp/oytc-tag-injection"
rm -f "$marker"
if ./scripts/package.sh 'v1.2.3"; touch /tmp/oytc-tag-injection; echo "' dist-invalid; then
echo "unsafe release version was accepted" >&2
exit 1
fi
newline_version="$(printf 'v1.2.3\n../../escape')"
if ./scripts/package.sh "$newline_version" dist-invalid; then
echo "multiline release version was accepted" >&2
exit 1
fi
for invalid_version in v01.2.3 v1.2.3-01; do
if ./scripts/package.sh "$invalid_version" dist-invalid; then
echo "non-canonical release version was accepted: $invalid_version" >&2
exit 1
fi
done
test ! -e "$marker"
- name: Skill structure
run: |
python3 - <<'EOF'
Expand Down Expand Up @@ -118,3 +148,33 @@ jobs:
OYTC_DOWNLOAD_BASE=http://127.0.0.1:8931 sh site/install.sh
"$PWD/fake-home/.local/bin/oytc" version
"$PWD/fake-home/.local/bin/oytc_update" --help >/dev/null
mkdir curl-shim
cat >curl-shim/curl <<'EOF'
#!/bin/sh
: >"${CURL_MARKER:?}"
exit 99
EOF
chmod +x curl-shim/curl
request_marker="$PWD/unexpected-curl-request"
rm -f "$request_marker"
if PATH="$PWD/curl-shim:$PATH" CURL_MARKER="$request_marker" \
HOME="$PWD/fake-home" OYTC_VERSION=v0.0.0-ci \
OYTC_DOWNLOAD_BASE='http://localhost' sh site/install.sh; then
echo "recording curl shim unexpectedly succeeded" >&2
exit 1
fi
test -e "$request_marker" || {
echo "installer rejected a loopback HTTP authority without a port" >&2
exit 1
}
rm -f "$request_marker"
if PATH="$PWD/curl-shim:$PATH" CURL_MARKER="$request_marker" \
HOME="$PWD/fake-home" OYTC_VERSION=v0.0.0-ci \
OYTC_DOWNLOAD_BASE='http://localhost:80@attacker.invalid' sh site/install.sh; then
echo "installer accepted a non-loopback HTTP authority" >&2
exit 1
fi
test ! -e "$request_marker" || {
echo "installer attempted an HTTP request for a hostile authority" >&2
exit 1
}
31 changes: 23 additions & 8 deletions .depot/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,43 @@ jobs:
else
tag="${GITHUB_REF_NAME}"
fi
case "$tag" in
v[0-9]*) ;;
*) echo "error: '$tag' is not a v-prefixed semantic version tag" >&2; exit 1 ;;
esac
echo "tag=$tag" >>"$GITHUB_OUTPUT"
tag_newlines="$(printf '%s' "$tag" | wc -l | tr -d '[:space:]')"
if [ "$tag_newlines" != "0" ] ||
! printf '%s\n' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "error: '$tag' is not a valid v-prefixed semantic version tag" >&2
exit 1
fi
printf 'tag=%s\n' "$tag" >>"$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.tag.outputs.tag }}
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Vulnerability scan (all release targets)
run: |
set -eu
go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
scanner="$(go env GOPATH)/bin/govulncheck"
for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
echo "== $platform =="
GOTOOLCHAIN=local GOOS="${platform%/*}" GOARCH="${platform#*/}" "$scanner" ./...
done
- name: Tests (race detector)
run: go test -race ./...
- name: Package all platforms
run: ./scripts/package.sh "${{ steps.tag.outputs.tag }}" dist
env:
TAG: ${{ steps.tag.outputs.tag }}
run: ./scripts/package.sh "$TAG" dist
- name: Smoke-test a packaged binary
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -eu
tar -xzf "dist/oytc_${{ steps.tag.outputs.tag }}_linux_amd64.tar.gz" -C /tmp oytc
tar -xzf "dist/oytc_${TAG}_linux_amd64.tar.gz" -C /tmp oytc
/tmp/oytc version
/tmp/oytc version --format json | grep -q '"version": "${{ steps.tag.outputs.tag }}"'
/tmp/oytc version --format json | grep -Fq "\"version\": \"${TAG}\""
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
Expand Down
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,31 @@ write commands.
macOS / Linux (verifies SHA-256 before installing; no root needed):

```sh
curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh
tmp="$(mktemp)" && {
curl --proto '=https' --proto-redir '=https' -fsSL \
https://davis7dotsh.github.io/open-yt-cli/install.sh -o "$tmp" &&
sh "$tmp"
status=$?
rm -f "$tmp"
(exit "$status")
}
```

Windows (PowerShell): `irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex`,
or download a zip from [releases](https://github.com/davis7dotsh/open-yt-cli/releases).
Windows (PowerShell):

From source (Go 1.26+): `go install ./cmd/oytc` from a clone, or `make build`.
```powershell
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("oytc-install-" + [Guid]::NewGuid() + ".ps1")
try {
irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 -OutFile $tmp -ErrorAction Stop
& $tmp
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
```

Alternatively, download a zip from [releases](https://github.com/davis7dotsh/open-yt-cli/releases).

From source (Go 1.26.5+): `go install ./cmd/oytc` from a clone, or `make build`.

## Quick start

Expand Down Expand Up @@ -91,6 +109,9 @@ hard-block unverified apps requesting it, so verify the consent app for those ac
- `status` shows a key fingerprint plus OAuth client ID, scopes, and expiry. It never prints
tokens or the client secret. `logout` attempts OAuth revocation, then removes the file.
- `oytc update` verifies release checksums and never reads or transmits credentials.
- Release checksums detect corruption or in-transit tampering. Because the checksum manifest
ships in the same release, publisher authenticity still relies on the GitHub repository and
release workflow; releases do not yet have an independent signature.

## Scope: read-only public data + your analytics

Expand Down
7 changes: 5 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Platforms: `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `window
- `site/install.sh` / `site/install.ps1` — download + verify + install
- `internal/update/update.go` (`AssetName`) — the self-updater

The checksum manifest and archives share the GitHub Release trust root. Checksums detect
corruption and in-transit tampering, but they do not protect against a compromised publisher.
Independent artifact signing is not currently configured.

Version metadata is injected via
`-ldflags -X open-yt-cli/internal/version.{Version,Commit,Date}=…` and surfaced by
`oytc version`.
Expand Down Expand Up @@ -111,8 +115,7 @@ Then verify:
`depot ci run list`) and
<https://github.com/davis7dotsh/open-yt-cli/releases> shows six archives plus
`checksums.txt`.
2. `curl -fsSL https://davis7dotsh.github.io/open-yt-cli/install.sh | sh` installs and
`oytc version` prints `v0.1.0`.
2. Download and run `install.sh`; `oytc version` prints `v0.1.0`.
3. `oytc update --check` reports up-to-date.

Subsequent releases: bump the tag (`v0.1.1`, `v0.2.0`, …) and push it. Prereleases: use a
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module open-yt-cli

go 1.26.0
go 1.26.5

require (
github.com/spf13/cobra v1.10.2
Expand All @@ -11,5 +11,5 @@ require (

require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/spf13/pflag v1.0.10 // indirect
)
3 changes: 2 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"regexp"
"strings"
"time"

Expand All @@ -12,6 +13,8 @@ import (
"open-yt-cli/internal/youtube"
)

var analyticsVideoIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)

type analyticsFlags struct {
start string
end string
Expand Down Expand Up @@ -90,6 +93,9 @@ func (a *App) analyticsVideoCommand() *cobra.Command {
Short: "Show core analytics metrics for one owned video",
Args: exactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !analyticsVideoIDPattern.MatchString(args[0]) {
return &UsageError{Message: "VIDEO_ID may contain only letters, digits, underscores, and hyphens"}
}
query := analytics.Query{Metrics: metrics, Filters: "video==" + args[0]}
return a.runAnalytics(cmd, flags, query, metrics)
},
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"

"open-yt-cli/internal/config"
"open-yt-cli/internal/youtube"
Expand Down Expand Up @@ -169,6 +170,10 @@ func TestAnalyticsRequiresOAuthAndValidDates(t *testing.T) {
if !errors.Is(err, youtube.ErrMissingOAuth) || !bytes.Contains([]byte(err.Error()), []byte("login --oauth")) {
t.Fatalf("expected missing OAuth hint, got %T: %v", err, err)
}
err = execute(t, app, "analytics", "video", "video;country==US")
if !errors.As(err, &usage) {
t.Fatalf("expected invalid video ID usage error, got %T: %v", err, err)
}
}

func TestStatusHidesOAuthSecretsAndLogoutRevokes(t *testing.T) {
Expand Down Expand Up @@ -429,6 +434,34 @@ func TestLiveChatStreamPollsWithTokenAndDeduplicates(t *testing.T) {
}
}

func TestRecentIDsEvictsOldEntries(t *testing.T) {
seen := newRecentIDs(2)
if !seen.Add("a") || !seen.Add("b") || seen.Add("a") {
t.Fatal("recent ID set did not detect a duplicate")
}
if !seen.Add("c") {
t.Fatal("recent ID set rejected a new ID")
}
if len(seen.values) != 2 {
t.Fatalf("stored IDs = %d, want 2", len(seen.values))
}
if !seen.Add("a") {
t.Fatal("oldest ID was not evicted")
}
}

func TestLiveChatPollingIntervalIsBounded(t *testing.T) {
if got := liveChatPollingInterval(0); got != time.Second {
t.Fatalf("zero interval = %v", got)
}
if got := liveChatPollingInterval(2500); got != 2500*time.Millisecond {
t.Fatalf("normal interval = %v", got)
}
if got := liveChatPollingInterval(999999999); got != maxLiveChatPollingInterval {
t.Fatalf("large interval = %v, want %v", got, maxLiveChatPollingInterval)
}
}

func TestCommentThreadsRejectsIncompatibleFiltersWithoutRequest(t *testing.T) {
t.Setenv("OYTC_CONFIG_DIR", t.TempDir())
t.Setenv("OYTC_API_KEY", "key")
Expand Down
53 changes: 43 additions & 10 deletions internal/cli/live_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import (
"open-yt-cli/internal/youtube"
)

const liveChatDedupWindow = 10000
const maxLiveChatPollingInterval = 60 * time.Second

func (a *App) liveChatCommand() *cobra.Command {
live := &cobra.Command{Use: "live-chat", Short: "Read public live chat using REST polling"}
live.AddCommand(a.liveChatListCommand(), a.liveChatStreamCommand())
Expand Down Expand Up @@ -78,7 +81,7 @@ func (a *App) liveChatStreamCommand() *cobra.Command {
if err != nil {
return err
}
seen := make(map[string]struct{})
seen := newRecentIDs(liveChatDedupWindow)
emitted := 0
firstPage := true
for {
Expand All @@ -93,11 +96,8 @@ func (a *App) liveChatStreamCommand() *cobra.Command {
items := make([]map[string]any, 0, len(response.Items))
for _, item := range response.Items {
id, _ := item["id"].(string)
if id != "" {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
if id != "" && !seen.Add(id) {
continue
}
items = append(items, item)
if flags.limit > 0 && emitted+len(items) >= flags.limit {
Expand All @@ -122,10 +122,7 @@ func (a *App) liveChatStreamCommand() *cobra.Command {
return nil
}
flags.pageToken = response.NextPageToken
interval := time.Duration(response.PollingIntervalMillis) * time.Millisecond
if interval <= 0 {
interval = time.Second
}
interval := liveChatPollingInterval(response.PollingIntervalMillis)
if err := waitFor(cmd.Context(), interval); err != nil {
if errors.Is(err, context.Canceled) {
return nil
Expand All @@ -139,6 +136,42 @@ func (a *App) liveChatStreamCommand() *cobra.Command {
return cmd
}

func liveChatPollingInterval(milliseconds int64) time.Duration {
if milliseconds <= 0 {
return time.Second
}
if milliseconds >= int64(maxLiveChatPollingInterval/time.Millisecond) {
return maxLiveChatPollingInterval
}
return time.Duration(milliseconds) * time.Millisecond
}

type recentIDs struct {
values map[string]struct{}
order []string
next int
capacity int
}

func newRecentIDs(capacity int) *recentIDs {
return &recentIDs{values: make(map[string]struct{}, capacity), order: make([]string, 0, capacity), capacity: capacity}
}

func (r *recentIDs) Add(value string) bool {
if _, exists := r.values[value]; exists {
return false
}
if len(r.order) < r.capacity {
r.order = append(r.order, value)
} else {
delete(r.values, r.order[r.next])
r.order[r.next] = value
r.next = (r.next + 1) % r.capacity
}
r.values[value] = struct{}{}
return true
}

func addLiveChatFlags(cmd *cobra.Command, flags *liveChatFlags) {
cmd.Flags().StringVar(&flags.videoID, "video", "", "live video ID (resolved to activeLiveChatId)")
cmd.Flags().StringVar(&flags.chatID, "chat-id", "", "live chat ID")
Expand Down
Loading