Unify Google CLI with auth, services, and CI
This commit is contained in:
parent
7984c5b9e4
commit
bfbc6e4323
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Install tools
|
||||
run: make tools
|
||||
- name: Format check
|
||||
run: make fmt-check
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.62.2
|
||||
args: --timeout=5m
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -30,3 +30,6 @@ go.work.sum
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
# Local tools
|
||||
.tools/
|
||||
|
||||
24
.golangci.yml
Normal file
24
.golangci.yml
Normal file
@ -0,0 +1,24 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
31
Makefile
Normal file
31
Makefile
Normal file
@ -0,0 +1,31 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: fmt fmt-check lint test ci tools
|
||||
|
||||
TOOLS_DIR := $(CURDIR)/.tools
|
||||
GOFUMPT := $(TOOLS_DIR)/gofumpt
|
||||
GOIMPORTS := $(TOOLS_DIR)/goimports
|
||||
GOLANGCI_LINT := $(TOOLS_DIR)/golangci-lint
|
||||
|
||||
tools:
|
||||
@mkdir -p $(TOOLS_DIR)
|
||||
@GOBIN=$(TOOLS_DIR) go install mvdan.cc/gofumpt@v0.7.0
|
||||
@GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.38.0
|
||||
@GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2
|
||||
|
||||
fmt: tools
|
||||
@$(GOIMPORTS) -w .
|
||||
@$(GOFUMPT) -w .
|
||||
|
||||
fmt-check: tools
|
||||
@$(GOIMPORTS) -w .
|
||||
@$(GOFUMPT) -w .
|
||||
@git diff --exit-code -- '*.go' go.mod go.sum
|
||||
|
||||
lint: tools
|
||||
@$(GOLANGCI_LINT) run
|
||||
|
||||
test:
|
||||
@go test ./...
|
||||
|
||||
ci: fmt-check lint test
|
||||
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# gogcli
|
||||
|
||||
Minimal Google (Gmail/Calendar/Drive/Contacts) CLI in Go.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create OAuth credentials in Google Cloud Console (Desktop app) and enable the APIs you need.
|
||||
2. Store credentials:
|
||||
- `gog auth credentials ~/path/to/credentials.json`
|
||||
3. Authorize (stores a refresh token in Keychain via keyring):
|
||||
- `gog auth add you@gmail.com` (default: all services)
|
||||
- or: `gog auth add you@gmail.com --services drive,calendar`
|
||||
|
||||
Most API commands require `--account you@gmail.com`.
|
||||
|
||||
### Output
|
||||
|
||||
- `--output=text` writes plain text to stdout (designed to be script-friendly).
|
||||
- `--output=json` writes JSON to stdout (best for scripting).
|
||||
- Human-facing hints/progress go to stderr.
|
||||
|
||||
### Environment
|
||||
|
||||
- `GOG_ACCOUNT=you@gmail.com` (used if `--account` is omitted)
|
||||
- `GOG_COLOR=auto|always|never` (default `auto`)
|
||||
- `GOG_OUTPUT=text|json` (default `text`)
|
||||
|
||||
### Integration tests (local only)
|
||||
|
||||
Run smoke tests against real APIs (not in CI):
|
||||
|
||||
- `GOG_IT_ACCOUNT=you@gmail.com go test -tags=integration ./internal/integration`
|
||||
|
||||
## Development
|
||||
|
||||
- Format: `make fmt`
|
||||
- Lint: `make lint`
|
||||
- Test: `make test`
|
||||
13
cmd/gog/main.go
Normal file
13
cmd/gog/main.go
Normal file
@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/steipete/gogcli/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.Execute(os.Args[1:]); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
279
docs/spec.md
Normal file
279
docs/spec.md
Normal file
@ -0,0 +1,279 @@
|
||||
# gogcli spec
|
||||
|
||||
## Goal
|
||||
|
||||
Build a single, clean, modern Go CLI that talks to:
|
||||
|
||||
- Gmail API
|
||||
- Google Calendar API
|
||||
- Google Drive API
|
||||
- Google People API (Contacts + directory)
|
||||
|
||||
This replaces the existing separate CLIs (`gmcli`, `gccli`, `gdcli`) and the Python contacts server conceptually, but:
|
||||
|
||||
- no backwards compatibility
|
||||
- no migration tooling
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Preserving legacy command names/flags/output formats
|
||||
- Importing existing `~/.gmcli`, `~/.gccli`, `~/.gdcli` state
|
||||
- Running an MCP server (this is a CLI)
|
||||
|
||||
## Language/runtime
|
||||
|
||||
- Go `1.25` (see `go.mod`)
|
||||
|
||||
## CLI framework
|
||||
|
||||
- `github.com/spf13/cobra`
|
||||
- Root command: `gog`
|
||||
- Global flag:
|
||||
- `--color=auto|always|never` (default `auto`)
|
||||
- `--output=text|json` (default `text`)
|
||||
|
||||
Notes:
|
||||
|
||||
- We run `SilenceUsage: true` and print errors ourselves (colored when possible).
|
||||
- `NO_COLOR` is respected.
|
||||
|
||||
Environment:
|
||||
|
||||
- `GOG_COLOR=auto|always|never` (default `auto`, overridden by `--color`)
|
||||
- `GOG_OUTPUT=text|json` (default `text`, overridden by `--output`)
|
||||
|
||||
## Output (TTY-aware colors)
|
||||
|
||||
- `github.com/muesli/termenv` is used to detect rich TTY capabilities and render colored output.
|
||||
- Colors are enabled when:
|
||||
- output is a rich terminal and `--color=auto`, and `NO_COLOR` is not set; or
|
||||
- `--color=always`
|
||||
- Colors are disabled when:
|
||||
- `--color=never`; or
|
||||
- `NO_COLOR` is set
|
||||
|
||||
Implementation: `internal/ui/ui.go`.
|
||||
|
||||
## Auth + secret storage
|
||||
|
||||
### OAuth client credentials (non-secret-ish)
|
||||
|
||||
- Stored on disk in the per-user config directory:
|
||||
- `$(os.UserConfigDir())/gogcli/credentials.json`
|
||||
- Written with mode `0600`.
|
||||
- Command:
|
||||
- `gog auth credentials <credentials.json>`
|
||||
- Supports Google’s downloaded JSON format:
|
||||
- `installed.client_id/client_secret` or `web.client_id/client_secret`
|
||||
|
||||
Implementation: `internal/config/*`.
|
||||
|
||||
### Refresh tokens (secrets)
|
||||
|
||||
- Stored in OS credential store via `github.com/99designs/keyring`.
|
||||
- Key namespace is `gogcli` (keyring `ServiceName`).
|
||||
- Key format: `token:<email>`
|
||||
- Stored payload is JSON (refresh token + metadata like selected services/scopes).
|
||||
|
||||
Current minimal management commands (implemented):
|
||||
|
||||
- `gog auth tokens list` (keys only)
|
||||
- `gog auth tokens delete <email>`
|
||||
|
||||
Implementation: `internal/secrets/store.go`.
|
||||
|
||||
### OAuth flow
|
||||
|
||||
- Desktop OAuth 2.0 flow using local HTTP redirect on an ephemeral port.
|
||||
- Supports a browserless/manual flow (paste redirect URL) for headless environments.
|
||||
- Refresh token issuance:
|
||||
- requests `access_type=offline`
|
||||
- supports `--force-consent` to force the consent prompt when Google doesn't return a refresh token
|
||||
- uses `include_granted_scopes=true` to support incremental auth re-runs
|
||||
|
||||
Scope selection note:
|
||||
|
||||
- The consent screen shows the scopes the CLI requested.
|
||||
- Users cannot selectively un-check individual requested scopes in the consent screen; they either approve all requested scopes or cancel.
|
||||
- To request fewer scopes, choose fewer services via `gog auth add --services ...`.
|
||||
|
||||
## Config layout
|
||||
|
||||
- Base config dir: `$(os.UserConfigDir())/gogcli/`
|
||||
- Files:
|
||||
- `credentials.json` (OAuth client id/secret)
|
||||
- Secrets:
|
||||
- refresh tokens in keyring
|
||||
|
||||
We intentionally avoid storing refresh tokens in plain JSON on disk.
|
||||
|
||||
Environment:
|
||||
|
||||
- `GOG_ACCOUNT=you@gmail.com` (used when `--account` is not set)
|
||||
|
||||
## Commands (current + planned)
|
||||
|
||||
### Implemented
|
||||
|
||||
- `gog auth credentials <credentials.json>`
|
||||
- `gog auth add <email> [--services all|gmail,calendar,drive,contacts] [--manual] [--force-consent]`
|
||||
- `gog auth list`
|
||||
- `gog auth remove <email>`
|
||||
- `gog auth tokens list`
|
||||
- `gog auth tokens delete <email>`
|
||||
- `gog drive ls [folderId] [--max N] [--page TOKEN] [--query Q]`
|
||||
- `gog drive search <text> [--max N] [--page TOKEN]`
|
||||
- `gog drive get <fileId>`
|
||||
- `gog drive download <fileId> [destPath]`
|
||||
- `gog drive upload <localPath> [--name N] [--folder ID]`
|
||||
- `gog drive mkdir <name> [--parent ID]`
|
||||
- `gog drive delete <fileId>`
|
||||
- `gog drive move <fileId> <newParentId>`
|
||||
- `gog drive rename <fileId> <newName>`
|
||||
- `gog drive share <fileId> [--anyone | --email addr] [--role reader|writer] [--discoverable]`
|
||||
- `gog drive permissions <fileId>`
|
||||
- `gog drive unshare <fileId> <permissionId>`
|
||||
- `gog drive url <fileIds...>`
|
||||
- `gog calendar calendars`
|
||||
- `gog calendar acl <calendarId>`
|
||||
- `gog calendar events <calendarId> [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q]`
|
||||
- `gog calendar event <calendarId> <eventId>`
|
||||
- `gog calendar create <calendarId> --summary S --start DT --end DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day]`
|
||||
- `gog calendar update <calendarId> <eventId> [--summary S] [--start DT] [--end DT] [--description D] [--location L] [--attendees ...] [--all-day]`
|
||||
- `gog calendar delete <calendarId> <eventId>`
|
||||
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
|
||||
- `gog gmail search <query> [--max N] [--page TOKEN]`
|
||||
- `gog gmail thread <threadId> [--download]`
|
||||
- `gog gmail url <threadIds...>`
|
||||
- `gog gmail labels list`
|
||||
- `gog gmail labels modify <threadIds...> [--add ...] [--remove ...]`
|
||||
- `gog gmail send --to a@b.com --subject S --body B [--cc ...] [--bcc ...] [--reply-to <messageId>] [--attach <file>...]`
|
||||
- `gog gmail drafts list [--max N] [--page TOKEN]`
|
||||
- `gog gmail drafts get <draftId> [--download]`
|
||||
- `gog gmail drafts create --to a@b.com --subject S --body B [--cc ...] [--bcc ...] [--reply-to <messageId>] [--attach <file>...]`
|
||||
- `gog gmail drafts send <draftId>`
|
||||
- `gog gmail drafts delete <draftId>`
|
||||
- `gog contacts search <query> [--max N]`
|
||||
- `gog contacts list [--max N] [--page TOKEN]`
|
||||
- `gog contacts get <people/...|email>`
|
||||
- `gog contacts create --given NAME [--family NAME] [--email addr] [--phone num]`
|
||||
- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num]`
|
||||
- `gog contacts delete <people/...>`
|
||||
- `gog contacts directory list [--max N] [--page TOKEN]`
|
||||
- `gog contacts directory search <query> [--max N] [--page TOKEN]`
|
||||
- `gog contacts other list [--max N] [--page TOKEN]`
|
||||
- `gog contacts other search <query> [--max N]`
|
||||
|
||||
### Planned high-level command tree
|
||||
|
||||
- `gog auth …`
|
||||
- `gog auth credentials <credentials.json>`
|
||||
- `gog gmail …`
|
||||
- `gog calendar …`
|
||||
- `gog drive …`
|
||||
- `gog contacts …`
|
||||
|
||||
Planned service identifiers (canonical):
|
||||
|
||||
- `gmail`
|
||||
- `calendar`
|
||||
- `drive`
|
||||
- `contacts`
|
||||
|
||||
## Google API dependencies (planned)
|
||||
|
||||
- `golang.org/x/oauth2`
|
||||
- `golang.org/x/oauth2/google`
|
||||
- `google.golang.org/api/option`
|
||||
- `google.golang.org/api/gmail/v1`
|
||||
- `google.golang.org/api/calendar/v3`
|
||||
- `google.golang.org/api/drive/v3`
|
||||
- `google.golang.org/api/people/v1`
|
||||
|
||||
## Scopes (planned)
|
||||
|
||||
We store a single refresh token per Google account email.
|
||||
|
||||
- `gog auth add` requests a union of scopes based on `--services`.
|
||||
- Each API client refreshes an access token for the subset of scopes needed for that service.
|
||||
- If you later want additional services, re-run `gog auth add <email> --services ...` (may require `--force-consent` to mint a new refresh token).
|
||||
|
||||
- Gmail: `https://mail.google.com/` (or narrower scopes if we decide later)
|
||||
- Calendar: `https://www.googleapis.com/auth/calendar`
|
||||
- Drive: `https://www.googleapis.com/auth/drive`
|
||||
- Contacts/Directory:
|
||||
- `https://www.googleapis.com/auth/contacts`
|
||||
- `https://www.googleapis.com/auth/directory.readonly`
|
||||
|
||||
## Output formats (planned)
|
||||
|
||||
Default: machine-friendly tabular output using stdlib `text/tabwriter`.
|
||||
|
||||
- Keep stdout stable and parseable:
|
||||
- `--output=text`: plain text, typically tab-separated for list/search style commands
|
||||
- `--output=json`: JSON objects/arrays suitable for scripting
|
||||
- Human-facing hints/progress are written to stderr so stdout can be safely captured.
|
||||
- Colors are only used for human-facing output and are disabled automatically for `--output=json`.
|
||||
|
||||
We avoid heavy table deps unless we decide we need them.
|
||||
|
||||
## Code layout (current)
|
||||
|
||||
- `cmd/gog/main.go` — binary entrypoint
|
||||
- `internal/cmd/*` — cobra commands
|
||||
- `internal/ui/*` — color + printing
|
||||
- `internal/config/*` — config paths + credential parsing/writing
|
||||
- `internal/secrets/*` — keyring store
|
||||
|
||||
## Formatting, linting, tests
|
||||
|
||||
### Formatting
|
||||
|
||||
Pinned tools, installed into local `.tools/` via `make tools`:
|
||||
|
||||
- `mvdan.cc/gofumpt@v0.7.0`
|
||||
- `golang.org/x/tools/cmd/goimports@v0.38.0`
|
||||
- `github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2`
|
||||
|
||||
Commands:
|
||||
|
||||
- `make fmt` — applies `goimports` + `gofumpt`
|
||||
- `make fmt-check` — formats and fails if Go files or `go.mod/go.sum` change
|
||||
|
||||
### Lint
|
||||
|
||||
- `golangci-lint` with config in `.golangci.yml`
|
||||
- `make lint`
|
||||
|
||||
### Tests
|
||||
|
||||
- stdlib `testing` (+ `httptest` when we add OAuth/API tests)
|
||||
- `make test`
|
||||
|
||||
### Integration tests (local only)
|
||||
|
||||
There is an opt-in integration test suite guarded by build tags (not run in CI).
|
||||
|
||||
- Requires:
|
||||
- stored `credentials.json` via `gog auth credentials ...`
|
||||
- refresh token in keyring via `gog auth add <email>`
|
||||
- Run:
|
||||
- `GOG_IT_ACCOUNT=you@gmail.com go test -tags=integration ./internal/integration`
|
||||
|
||||
## CI (GitHub Actions)
|
||||
|
||||
Workflow: `.github/workflows/ci.yml`
|
||||
|
||||
- runs on push + PR
|
||||
- uses `actions/setup-go` with `go-version-file: go.mod`
|
||||
- runs:
|
||||
- `make tools`
|
||||
- `make fmt-check`
|
||||
- `go test ./...`
|
||||
- `golangci-lint` (pinned `v1.62.2`)
|
||||
|
||||
## Next implementation steps
|
||||
|
||||
- Expand Gmail further (labels by name everywhere, richer body rendering, compose edge cases).
|
||||
- Improve People updates (multi-field + richer contact data).
|
||||
- Harden UX (consistent output formats, retries/backoff on specific transient errors).
|
||||
49
go.mod
Normal file
49
go.mod
Normal file
@ -0,0 +1,49 @@
|
||||
module github.com/steipete/gogcli
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/99designs/keyring v1.2.2
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.257.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mtibben/percent v0.2.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
129
go.sum
Normal file
129
go.sum
Normal file
@ -0,0 +1,129 @@
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
|
||||
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
|
||||
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
17
internal/cmd/account.go
Normal file
17
internal/cmd/account.go
Normal file
@ -0,0 +1,17 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func requireAccount(flags *rootFlags) (string, error) {
|
||||
if v := strings.TrimSpace(flags.Account); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("GOG_ACCOUNT")); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
return "", errors.New("missing --account (or set GOG_ACCOUNT)")
|
||||
}
|
||||
299
internal/cmd/auth.go
Normal file
299
internal/cmd/auth.go
Normal file
@ -0,0 +1,299 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/secrets"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func newAuthCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authentication and accounts",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newAuthCredentialsCmd())
|
||||
cmd.AddCommand(newAuthAddCmd())
|
||||
cmd.AddCommand(newAuthListCmd())
|
||||
cmd.AddCommand(newAuthRemoveCmd())
|
||||
cmd.AddCommand(newAuthTokensCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthCredentialsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "credentials <credentials.json>",
|
||||
Short: "Store OAuth client credentials",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
inPath := args[0]
|
||||
b, err := os.ReadFile(inPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
creds, err := config.ParseGoogleOAuthClientJSON(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := config.WriteClientCredentials(creds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outPath, _ := config.ClientCredentialsPath()
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"saved": true,
|
||||
"path": outPath,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("path\t%s", outPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthTokensCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "tokens",
|
||||
Short: "Manage stored refresh tokens",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List stored tokens (by key only)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"keys": []string{}})
|
||||
}
|
||||
u.Err().Println("No tokens stored")
|
||||
return nil
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"keys": keys})
|
||||
}
|
||||
for _, k := range keys {
|
||||
u.Out().Println(k)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "delete <email>",
|
||||
Short: "Delete a stored refresh token",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email := args[0]
|
||||
if err := store.DeleteToken(email); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"deleted": true,
|
||||
"email": email,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("email\t%s", email)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthAddCmd() *cobra.Command {
|
||||
var manual bool
|
||||
var forceConsent bool
|
||||
var servicesCSV string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <email>",
|
||||
Short: "Authorize and store a refresh token",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
|
||||
email := args[0]
|
||||
|
||||
var services []googleauth.Service
|
||||
if strings.EqualFold(strings.TrimSpace(servicesCSV), "") || strings.EqualFold(strings.TrimSpace(servicesCSV), "all") {
|
||||
services = googleauth.AllServices()
|
||||
} else {
|
||||
parts := strings.Split(servicesCSV, ",")
|
||||
seen := make(map[googleauth.Service]struct{})
|
||||
for _, p := range parts {
|
||||
svc, err := googleauth.ParseService(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[svc]; ok {
|
||||
continue
|
||||
}
|
||||
seen[svc] = struct{}{}
|
||||
services = append(services, svc)
|
||||
}
|
||||
}
|
||||
if len(services) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
scopes, err := googleauth.ScopesForServices(services)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refreshToken, err := googleauth.Authorize(cmd.Context(), googleauth.AuthorizeOptions{
|
||||
Services: services,
|
||||
Scopes: scopes,
|
||||
Manual: manual,
|
||||
ForceConsent: forceConsent,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serviceNames := make([]string, 0, len(services))
|
||||
for _, svc := range services {
|
||||
serviceNames = append(serviceNames, string(svc))
|
||||
}
|
||||
sort.Strings(serviceNames)
|
||||
|
||||
if err := store.SetToken(email, secrets.Token{
|
||||
Email: email,
|
||||
Services: serviceNames,
|
||||
Scopes: scopes,
|
||||
RefreshToken: refreshToken,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"stored": true,
|
||||
"email": email,
|
||||
"services": serviceNames,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("email\t%s", email)
|
||||
u.Out().Printf("services\t%s", strings.Join(serviceNames, ","))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&manual, "manual", false, "Browserless auth flow (paste redirect URL)")
|
||||
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen to obtain a refresh token")
|
||||
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List stored accounts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokens, err := store.ListTokens()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(tokens, func(i, j int) bool { return tokens[i].Email < tokens[j].Email })
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Email string `json:"email"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
out := make([]item, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
created := ""
|
||||
if !t.CreatedAt.IsZero() {
|
||||
created = t.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
out = append(out, item{
|
||||
Email: t.Email,
|
||||
Services: t.Services,
|
||||
Scopes: t.Scopes,
|
||||
CreatedAt: created,
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"accounts": out})
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
u.Err().Println("No tokens stored")
|
||||
return nil
|
||||
}
|
||||
for _, t := range tokens {
|
||||
created := ""
|
||||
if !t.CreatedAt.IsZero() {
|
||||
created = t.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
u.Out().Printf("%s\t%s\t%s", t.Email, strings.Join(t.Services, ","), created)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newAuthRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <email>",
|
||||
Short: "Remove a stored refresh token",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
email := args[0]
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.DeleteToken(email); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"deleted": true,
|
||||
"email": email,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("email\t%s", email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
591
internal/cmd/calendar.go
Normal file
591
internal/cmd/calendar.go
Normal file
@ -0,0 +1,591 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
)
|
||||
|
||||
func newCalendarCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "calendar",
|
||||
Short: "Google Calendar",
|
||||
}
|
||||
cmd.AddCommand(newCalendarCalendarsCmd(flags))
|
||||
cmd.AddCommand(newCalendarAclCmd(flags))
|
||||
cmd.AddCommand(newCalendarEventsCmd(flags))
|
||||
cmd.AddCommand(newCalendarEventCmd(flags))
|
||||
cmd.AddCommand(newCalendarCreateCmd(flags))
|
||||
cmd.AddCommand(newCalendarUpdateCmd(flags))
|
||||
cmd.AddCommand(newCalendarDeleteCmd(flags))
|
||||
cmd.AddCommand(newCalendarFreeBusyCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarCalendarsCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "calendars",
|
||||
Short: "List calendars",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.CalendarList.List().Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"calendars": resp.Items})
|
||||
}
|
||||
if len(resp.Items) == 0 {
|
||||
u.Err().Println("No calendars")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tROLE")
|
||||
for _, c := range resp.Items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Id, c.Summary, c.AccessRole)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCalendarAclCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "acl <calendarId>",
|
||||
Short: "List access control rules for a calendar",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Acl.List(calendarID).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"rules": resp.Items})
|
||||
}
|
||||
if len(resp.Items) == 0 {
|
||||
u.Err().Println("No ACL rules")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "SCOPE_TYPE\tSCOPE_VALUE\tROLE")
|
||||
for _, rule := range resp.Items {
|
||||
scopeType := ""
|
||||
scopeValue := ""
|
||||
if rule.Scope != nil {
|
||||
scopeType = rule.Scope.Type
|
||||
scopeValue = rule.Scope.Value
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", scopeType, scopeValue, rule.Role)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
|
||||
var from string
|
||||
var to string
|
||||
var max int64
|
||||
var page string
|
||||
var query string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "events <calendarId>",
|
||||
Short: "List events from a calendar",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
|
||||
now := time.Now().UTC()
|
||||
oneWeekLater := now.Add(7 * 24 * time.Hour)
|
||||
if strings.TrimSpace(from) == "" {
|
||||
from = now.Format(time.RFC3339)
|
||||
}
|
||||
if strings.TrimSpace(to) == "" {
|
||||
to = oneWeekLater.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
call := svc.Events.List(calendarID).
|
||||
TimeMin(from).
|
||||
TimeMax(to).
|
||||
MaxResults(max).
|
||||
PageToken(page).
|
||||
SingleEvents(true).
|
||||
OrderBy("startTime")
|
||||
if strings.TrimSpace(query) != "" {
|
||||
call = call.Q(query)
|
||||
}
|
||||
resp, err := call.Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"events": resp.Items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.Items) == 0 {
|
||||
u.Err().Println("No events")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tSTART\tEND\tSUMMARY")
|
||||
for _, e := range resp.Items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339; default: now)")
|
||||
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339; default: +7d)")
|
||||
cmd.Flags().Int64Var(&max, "max", 10, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
cmd.Flags().StringVar(&query, "query", "", "Free text search")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarEventCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "event <calendarId> <eventId>",
|
||||
Short: "Get event details",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
eventID := args[1]
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e, err := svc.Events.Get(calendarID, eventID).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": e})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", e.Id)
|
||||
u.Out().Printf("summary\t%s", orEmpty(e.Summary, "(no title)"))
|
||||
u.Out().Printf("start\t%s", eventStart(e))
|
||||
u.Out().Printf("end\t%s", eventEnd(e))
|
||||
if e.Location != "" {
|
||||
u.Out().Printf("location\t%s", e.Location)
|
||||
}
|
||||
if e.Description != "" {
|
||||
u.Out().Printf("description\t%s", e.Description)
|
||||
}
|
||||
if len(e.Attendees) > 0 {
|
||||
addrs := make([]string, 0, len(e.Attendees))
|
||||
for _, a := range e.Attendees {
|
||||
if a != nil && a.Email != "" {
|
||||
addrs = append(addrs, a.Email)
|
||||
}
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
u.Out().Printf("attendees\t%s", strings.Join(addrs, ", "))
|
||||
}
|
||||
}
|
||||
if e.Status != "" {
|
||||
u.Out().Printf("status\t%s", e.Status)
|
||||
}
|
||||
if e.HtmlLink != "" {
|
||||
u.Out().Printf("link\t%s", e.HtmlLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCalendarCreateCmd(flags *rootFlags) *cobra.Command {
|
||||
var summary string
|
||||
var start string
|
||||
var end string
|
||||
var description string
|
||||
var location string
|
||||
var attendees string
|
||||
var allDay bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <calendarId>",
|
||||
Short: "Create a new event",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
|
||||
if strings.TrimSpace(summary) == "" || strings.TrimSpace(start) == "" || strings.TrimSpace(end) == "" {
|
||||
return errors.New("required: --summary, --start, --end")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := &calendar.Event{
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Location: location,
|
||||
Start: buildEventDateTime(start, allDay),
|
||||
End: buildEventDateTime(end, allDay),
|
||||
Attendees: buildAttendees(attendees),
|
||||
}
|
||||
|
||||
created, err := svc.Events.Insert(calendarID, event).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": created})
|
||||
}
|
||||
u.Out().Printf("id\t%s", created.Id)
|
||||
if created.HtmlLink != "" {
|
||||
u.Out().Printf("link\t%s", created.HtmlLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&summary, "summary", "", "Event title (required)")
|
||||
cmd.Flags().StringVar(&start, "start", "", "Start time/date (required)")
|
||||
cmd.Flags().StringVar(&end, "end", "", "End time/date (required)")
|
||||
cmd.Flags().StringVar(&description, "description", "", "Event description")
|
||||
cmd.Flags().StringVar(&location, "location", "", "Event location")
|
||||
cmd.Flags().StringVar(&attendees, "attendees", "", "Attendees (comma-separated)")
|
||||
cmd.Flags().BoolVar(&allDay, "all-day", false, "Create all-day event (use YYYY-MM-DD for start/end)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarUpdateCmd(flags *rootFlags) *cobra.Command {
|
||||
var summary string
|
||||
var start string
|
||||
var end string
|
||||
var description string
|
||||
var location string
|
||||
var attendees string
|
||||
var allDay bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <calendarId> <eventId>",
|
||||
Short: "Update an existing event",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
eventID := args[1]
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := svc.Events.Get(calendarID, eventID).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetAllDay := isAllDayEvent(existing)
|
||||
if cmd.Flags().Changed("all-day") {
|
||||
targetAllDay = allDay
|
||||
// Converting between all-day and timed needs explicit start/end.
|
||||
if !cmd.Flags().Changed("start") || !cmd.Flags().Changed("end") {
|
||||
return errors.New("when changing --all-day, also provide --start and --end")
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
if cmd.Flags().Changed("summary") {
|
||||
existing.Summary = summary
|
||||
changed = true
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
existing.Description = description
|
||||
changed = true
|
||||
}
|
||||
if cmd.Flags().Changed("location") {
|
||||
existing.Location = location
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("start") {
|
||||
existing.Start = buildEventDateTime(start, targetAllDay)
|
||||
changed = true
|
||||
}
|
||||
if cmd.Flags().Changed("end") {
|
||||
existing.End = buildEventDateTime(end, targetAllDay)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("attendees") {
|
||||
existing.Attendees = buildAttendees(attendees)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return errors.New("no updates provided")
|
||||
}
|
||||
|
||||
updated, err := svc.Events.Update(calendarID, eventID, existing).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": updated})
|
||||
}
|
||||
u.Out().Printf("id\t%s", updated.Id)
|
||||
if updated.HtmlLink != "" {
|
||||
u.Out().Printf("link\t%s", updated.HtmlLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&summary, "summary", "", "Event title")
|
||||
cmd.Flags().StringVar(&start, "start", "", "Start time/date (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&end, "end", "", "End time/date (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&description, "description", "", "Event description")
|
||||
cmd.Flags().StringVar(&location, "location", "", "Event location")
|
||||
cmd.Flags().StringVar(&attendees, "attendees", "", "Attendees (comma-separated)")
|
||||
cmd.Flags().BoolVar(&allDay, "all-day", false, "Treat start/end as all-day (YYYY-MM-DD)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCalendarDeleteCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <calendarId> <eventId>",
|
||||
Short: "Delete an event",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarID := args[0]
|
||||
eventID := args[1]
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Events.Delete(calendarID, eventID).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"deleted": true,
|
||||
"calendarId": calendarID,
|
||||
"eventId": eventID,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("calendar_id\t%s", calendarID)
|
||||
u.Out().Printf("event_id\t%s", eventID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCalendarFreeBusyCmd(flags *rootFlags) *cobra.Command {
|
||||
var from string
|
||||
var to string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "freebusy <calendarIds>",
|
||||
Short: "Check free/busy status for calendars (comma-separated IDs)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendarIDs := splitCSV(args[0])
|
||||
if len(calendarIDs) == 0 {
|
||||
return errors.New("no calendar IDs provided")
|
||||
}
|
||||
if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" {
|
||||
return errors.New("required: --from and --to")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewCalendar(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs))
|
||||
for _, id := range calendarIDs {
|
||||
items = append(items, &calendar.FreeBusyRequestItem{Id: id})
|
||||
}
|
||||
|
||||
resp, err := svc.Freebusy.Query(&calendar.FreeBusyRequest{
|
||||
TimeMin: from,
|
||||
TimeMax: to,
|
||||
Items: items,
|
||||
}).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"calendars": resp.Calendars})
|
||||
}
|
||||
if len(resp.Calendars) == 0 {
|
||||
u.Err().Println("No data")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "CALENDAR\tSTART\tEND")
|
||||
for id, data := range resp.Calendars {
|
||||
for _, b := range data.Busy {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", id, b.Start, b.End)
|
||||
}
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339, required)")
|
||||
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339, required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildEventDateTime(value string, allDay bool) *calendar.EventDateTime {
|
||||
value = strings.TrimSpace(value)
|
||||
if allDay {
|
||||
return &calendar.EventDateTime{Date: value}
|
||||
}
|
||||
return &calendar.EventDateTime{DateTime: value}
|
||||
}
|
||||
|
||||
func buildAttendees(csv string) []*calendar.EventAttendee {
|
||||
addrs := splitCSV(csv)
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*calendar.EventAttendee, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, &calendar.EventAttendee{Email: a})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func eventStart(e *calendar.Event) string {
|
||||
if e == nil || e.Start == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Start.DateTime != "" {
|
||||
return e.Start.DateTime
|
||||
}
|
||||
return e.Start.Date
|
||||
}
|
||||
|
||||
func eventEnd(e *calendar.Event) string {
|
||||
if e == nil || e.End == nil {
|
||||
return ""
|
||||
}
|
||||
if e.End.DateTime != "" {
|
||||
return e.End.DateTime
|
||||
}
|
||||
return e.End.Date
|
||||
}
|
||||
|
||||
func isAllDayEvent(e *calendar.Event) bool {
|
||||
return e != nil && e.Start != nil && e.Start.Date != ""
|
||||
}
|
||||
|
||||
func orEmpty(s string, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
37
internal/cmd/calendar_test.go
Normal file
37
internal/cmd/calendar_test.go
Normal file
@ -0,0 +1,37 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
)
|
||||
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
if got := splitCSV(""); got != nil {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
got := splitCSV(" a@b.com, c@d.com ,,")
|
||||
if len(got) != 2 || got[0] != "a@b.com" || got[1] != "c@d.com" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEventDateTime(t *testing.T) {
|
||||
allDay := buildEventDateTime("2025-01-01", true)
|
||||
if allDay.Date != "2025-01-01" || allDay.DateTime != "" {
|
||||
t.Fatalf("unexpected: %#v", allDay)
|
||||
}
|
||||
timed := buildEventDateTime("2025-01-01T10:00:00Z", false)
|
||||
if timed.DateTime != "2025-01-01T10:00:00Z" || timed.Date != "" {
|
||||
t.Fatalf("unexpected: %#v", timed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllDayEvent(t *testing.T) {
|
||||
if isAllDayEvent(nil) {
|
||||
t.Fatalf("expected false")
|
||||
}
|
||||
if !isAllDayEvent(&calendar.Event{Start: &calendar.EventDateTime{Date: "2025-01-01"}}) {
|
||||
t.Fatalf("expected true")
|
||||
}
|
||||
}
|
||||
134
internal/cmd/contacts.go
Normal file
134
internal/cmd/contacts.go
Normal file
@ -0,0 +1,134 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
|
||||
func newContactsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "contacts",
|
||||
Short: "Google Contacts (People API)",
|
||||
}
|
||||
cmd.AddCommand(newContactsSearchCmd(flags))
|
||||
cmd.AddCommand(newContactsListCmd(flags))
|
||||
cmd.AddCommand(newContactsGetCmd(flags))
|
||||
cmd.AddCommand(newContactsCreateCmd(flags))
|
||||
cmd.AddCommand(newContactsUpdateCmd(flags))
|
||||
cmd.AddCommand(newContactsDeleteCmd(flags))
|
||||
cmd.AddCommand(newContactsDirectoryCmd(flags))
|
||||
cmd.AddCommand(newContactsOtherCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search contacts by name/email/phone",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.People.SearchContacts().
|
||||
Query(query).
|
||||
PageSize(max).
|
||||
ReadMask("names,emailAddresses,phoneNumbers").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
p := r.Person
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
Phone: primaryPhone(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"contacts": items})
|
||||
}
|
||||
if len(resp.Results) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
|
||||
for _, r := range resp.Results {
|
||||
p := r.Person
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
sanitizeTab(primaryPhone(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func primaryName(p *people.Person) string {
|
||||
if p == nil || len(p.Names) == 0 || p.Names[0] == nil {
|
||||
return ""
|
||||
}
|
||||
if p.Names[0].DisplayName != "" {
|
||||
return p.Names[0].DisplayName
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{p.Names[0].GivenName, p.Names[0].FamilyName}, " "))
|
||||
}
|
||||
|
||||
func primaryEmail(p *people.Person) string {
|
||||
if p == nil || len(p.EmailAddresses) == 0 || p.EmailAddresses[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return p.EmailAddresses[0].Value
|
||||
}
|
||||
|
||||
func primaryPhone(p *people.Person) string {
|
||||
if p == nil || len(p.PhoneNumbers) == 0 || p.PhoneNumbers[0] == nil {
|
||||
return ""
|
||||
}
|
||||
return p.PhoneNumbers[0].Value
|
||||
}
|
||||
358
internal/cmd/contacts_crud.go
Normal file
358
internal/cmd/contacts_crud.go
Normal file
@ -0,0 +1,358 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
|
||||
const contactsReadMask = "names,emailAddresses,phoneNumbers"
|
||||
|
||||
func newContactsListCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List contacts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.People.Connections.List("people/me").
|
||||
PersonFields(contactsReadMask).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.Connections))
|
||||
for _, p := range resp.Connections {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
Phone: primaryPhone(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"contacts": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
if len(resp.Connections) == 0 {
|
||||
u.Err().Println("No contacts")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
|
||||
for _, p := range resp.Connections {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
sanitizeTab(primaryPhone(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 100, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsGetCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <resourceName|email>",
|
||||
Short: "Get a contact by resource name (people/...) or email",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identifier := strings.TrimSpace(args[0])
|
||||
if identifier == "" {
|
||||
return errors.New("empty identifier")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var p *people.Person
|
||||
if strings.HasPrefix(identifier, "people/") {
|
||||
p, err = svc.People.Get(identifier).PersonFields(contactsReadMask).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Fallback: search and pick first match.
|
||||
resp, err := svc.People.SearchContacts().
|
||||
Query(identifier).
|
||||
PageSize(10).
|
||||
ReadMask(contactsReadMask).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range resp.Results {
|
||||
if r.Person == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(primaryEmail(r.Person), identifier) {
|
||||
p = r.Person
|
||||
break
|
||||
}
|
||||
if p == nil {
|
||||
p = r.Person
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"found": false})
|
||||
}
|
||||
u.Err().Println("Not found")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": p})
|
||||
}
|
||||
|
||||
u.Out().Printf("resource\t%s", p.ResourceName)
|
||||
u.Out().Printf("name\t%s", primaryName(p))
|
||||
if e := primaryEmail(p); e != "" {
|
||||
u.Out().Printf("email\t%s", e)
|
||||
}
|
||||
if ph := primaryPhone(p); ph != "" {
|
||||
u.Out().Printf("phone\t%s", ph)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newContactsCreateCmd(flags *rootFlags) *cobra.Command {
|
||||
var given string
|
||||
var family string
|
||||
var email string
|
||||
var phone string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new contact",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(given) == "" {
|
||||
return errors.New("required: --given")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p := &people.Person{
|
||||
Names: []*people.Name{{
|
||||
GivenName: strings.TrimSpace(given),
|
||||
FamilyName: strings.TrimSpace(family),
|
||||
}},
|
||||
}
|
||||
if strings.TrimSpace(email) != "" {
|
||||
p.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(email)}}
|
||||
}
|
||||
if strings.TrimSpace(phone) != "" {
|
||||
p.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(phone)}}
|
||||
}
|
||||
|
||||
created, err := svc.People.CreateContact(p).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": created})
|
||||
}
|
||||
u.Out().Printf("resource\t%s", created.ResourceName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&given, "given", "", "Given name (required)")
|
||||
cmd.Flags().StringVar(&family, "family", "", "Family name")
|
||||
cmd.Flags().StringVar(&email, "email", "", "Email address")
|
||||
cmd.Flags().StringVar(&phone, "phone", "", "Phone number")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsUpdateCmd(flags *rootFlags) *cobra.Command {
|
||||
var given string
|
||||
var family string
|
||||
var email string
|
||||
var phone string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <resourceName>",
|
||||
Short: "Update an existing contact",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceName := strings.TrimSpace(args[0])
|
||||
if !strings.HasPrefix(resourceName, "people/") {
|
||||
return errors.New("resourceName must start with people/")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateFields := make([]string, 0, 3)
|
||||
|
||||
if cmd.Flags().Changed("given") || cmd.Flags().Changed("family") {
|
||||
curGiven := ""
|
||||
curFamily := ""
|
||||
if len(existing.Names) > 0 && existing.Names[0] != nil {
|
||||
curGiven = existing.Names[0].GivenName
|
||||
curFamily = existing.Names[0].FamilyName
|
||||
}
|
||||
if cmd.Flags().Changed("given") {
|
||||
curGiven = strings.TrimSpace(given)
|
||||
}
|
||||
if cmd.Flags().Changed("family") {
|
||||
curFamily = strings.TrimSpace(family)
|
||||
}
|
||||
name := &people.Name{GivenName: curGiven, FamilyName: curFamily}
|
||||
existing.Names = []*people.Name{name}
|
||||
updateFields = append(updateFields, "names")
|
||||
}
|
||||
if cmd.Flags().Changed("email") {
|
||||
if strings.TrimSpace(email) == "" {
|
||||
existing.EmailAddresses = nil
|
||||
} else {
|
||||
existing.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(email)}}
|
||||
}
|
||||
updateFields = append(updateFields, "emailAddresses")
|
||||
}
|
||||
if cmd.Flags().Changed("phone") {
|
||||
if strings.TrimSpace(phone) == "" {
|
||||
existing.PhoneNumbers = nil
|
||||
} else {
|
||||
existing.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(phone)}}
|
||||
}
|
||||
updateFields = append(updateFields, "phoneNumbers")
|
||||
}
|
||||
|
||||
if len(updateFields) == 0 {
|
||||
return errors.New("no updates provided")
|
||||
}
|
||||
|
||||
updated, err := svc.People.UpdateContact(resourceName, existing).
|
||||
UpdatePersonFields(strings.Join(updateFields, ",")).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"contact": updated})
|
||||
}
|
||||
u.Out().Printf("resource\t%s", updated.ResourceName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&given, "given", "", "Given name")
|
||||
cmd.Flags().StringVar(&family, "family", "", "Family name")
|
||||
cmd.Flags().StringVar(&email, "email", "", "Email address (empty clears)")
|
||||
cmd.Flags().StringVar(&phone, "phone", "", "Phone number (empty clears)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsDeleteCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <resourceName>",
|
||||
Short: "Delete a contact",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceName := strings.TrimSpace(args[0])
|
||||
if !strings.HasPrefix(resourceName, "people/") {
|
||||
return errors.New("resourceName must start with people/")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := svc.People.DeleteContact(resourceName).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "resource": resourceName})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("resource\t%s", resourceName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
365
internal/cmd/contacts_directory.go
Normal file
365
internal/cmd/contacts_directory.go
Normal file
@ -0,0 +1,365 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
const directoryReadMask = "names,emailAddresses"
|
||||
|
||||
func newContactsDirectoryCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "directory",
|
||||
Short: "Google Workspace directory",
|
||||
}
|
||||
cmd.AddCommand(newContactsDirectoryListCmd(flags))
|
||||
cmd.AddCommand(newContactsDirectorySearchCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsDirectoryListCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List people from the Workspace directory",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.People.ListDirectoryPeople().
|
||||
ReadMask(directoryReadMask).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.People))
|
||||
for _, p := range resp.People {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"people": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.People) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
|
||||
for _, p := range resp.People {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsDirectorySearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search people in the Workspace directory",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.People.SearchDirectoryPeople().
|
||||
Query(query).
|
||||
ReadMask(directoryReadMask).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.People))
|
||||
for _, p := range resp.People {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"people": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.People) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL")
|
||||
for _, p := range resp.People {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsOtherCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "other",
|
||||
Short: "Other contacts (people you've interacted with)",
|
||||
}
|
||||
cmd.AddCommand(newContactsOtherListCmd(flags))
|
||||
cmd.AddCommand(newContactsOtherSearchCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsOtherListCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List other contacts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.OtherContacts.List().
|
||||
ReadMask(contactsReadMask).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.OtherContacts))
|
||||
for _, p := range resp.OtherContacts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
Phone: primaryPhone(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"contacts": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.OtherContacts) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
|
||||
for _, p := range resp.OtherContacts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
sanitizeTab(primaryPhone(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 100, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactsOtherSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search other contacts",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
|
||||
svc, err := googleapi.NewPeople(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.OtherContacts.Search().
|
||||
Query(query).
|
||||
ReadMask(contactsReadMask).
|
||||
PageSize(max).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
Resource string `json:"resource"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
p := r.Person
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{
|
||||
Resource: p.ResourceName,
|
||||
Name: primaryName(p),
|
||||
Email: primaryEmail(p),
|
||||
Phone: primaryPhone(p),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"contacts": items})
|
||||
}
|
||||
|
||||
if len(resp.Results) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "RESOURCE\tNAME\tEMAIL\tPHONE")
|
||||
for _, r := range resp.Results {
|
||||
p := r.Person
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
|
||||
p.ResourceName,
|
||||
sanitizeTab(primaryName(p)),
|
||||
sanitizeTab(primaryEmail(p)),
|
||||
sanitizeTab(primaryPhone(p)),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 50, "Max results")
|
||||
return cmd
|
||||
}
|
||||
35
internal/cmd/contacts_test.go
Normal file
35
internal/cmd/contacts_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/people/v1"
|
||||
)
|
||||
|
||||
func TestPrimaryName(t *testing.T) {
|
||||
p := &people.Person{
|
||||
Names: []*people.Name{
|
||||
{DisplayName: "Ada Lovelace", GivenName: "Ada", FamilyName: "Lovelace"},
|
||||
},
|
||||
}
|
||||
if got := primaryName(p); got != "Ada Lovelace" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
p.Names[0].DisplayName = ""
|
||||
if got := primaryName(p); got != "Ada Lovelace" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryEmailPhone(t *testing.T) {
|
||||
p := &people.Person{
|
||||
EmailAddresses: []*people.EmailAddress{{Value: "a@b.com"}},
|
||||
PhoneNumbers: []*people.PhoneNumber{{Value: "+1 555 0100"}},
|
||||
}
|
||||
if got := primaryEmail(p); got != "a@b.com" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := primaryPhone(p); got != "+1 555 0100" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
945
internal/cmd/drive.go
Normal file
945
internal/cmd/drive.go
Normal file
@ -0,0 +1,945 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/drive/v3"
|
||||
gapi "google.golang.org/api/googleapi"
|
||||
)
|
||||
|
||||
func newDriveCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "Google Drive",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newDriveLsCmd(flags))
|
||||
cmd.AddCommand(newDriveSearchCmd(flags))
|
||||
cmd.AddCommand(newDriveGetCmd(flags))
|
||||
cmd.AddCommand(newDriveDownloadCmd(flags))
|
||||
cmd.AddCommand(newDriveUploadCmd(flags))
|
||||
cmd.AddCommand(newDriveMkdirCmd(flags))
|
||||
cmd.AddCommand(newDriveDeleteCmd(flags))
|
||||
cmd.AddCommand(newDriveMoveCmd(flags))
|
||||
cmd.AddCommand(newDriveRenameCmd(flags))
|
||||
cmd.AddCommand(newDriveShareCmd(flags))
|
||||
cmd.AddCommand(newDriveUnshareCmd(flags))
|
||||
cmd.AddCommand(newDrivePermissionsCmd(flags))
|
||||
cmd.AddCommand(newDriveURLCmd(flags))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveLsCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
var query string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls [folderId]",
|
||||
Short: "List files in a folder (default: root)",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folderID := "root"
|
||||
if len(args) == 1 {
|
||||
folderID = args[0]
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := buildDriveListQuery(folderID, query)
|
||||
|
||||
resp, err := svc.Files.List().
|
||||
Q(q).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
OrderBy("modifiedTime desc").
|
||||
SupportsAllDrives(true).
|
||||
IncludeItemsFromAllDrives(true).
|
||||
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"files": resp.Files,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.Files) == 0 {
|
||||
u.Err().Println("No files")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
|
||||
for _, f := range resp.Files {
|
||||
fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\n",
|
||||
f.Id,
|
||||
f.Name,
|
||||
driveType(f.MimeType),
|
||||
formatDriveSize(f.Size),
|
||||
formatDateTime(f.ModifiedTime),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
cmd.Flags().StringVar(&query, "query", "", "Drive query filter")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <text>",
|
||||
Short: "Full-text search across Drive",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := strings.Join(args, " ")
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Files.List().
|
||||
Q(buildDriveSearchQuery(text)).
|
||||
PageSize(max).
|
||||
PageToken(page).
|
||||
OrderBy("modifiedTime desc").
|
||||
SupportsAllDrives(true).
|
||||
IncludeItemsFromAllDrives(true).
|
||||
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"files": resp.Files,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.Files) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSIZE\tMODIFIED")
|
||||
for _, f := range resp.Files {
|
||||
fmt.Fprintf(
|
||||
tw,
|
||||
"%s\t%s\t%s\t%s\t%s\n",
|
||||
f.Id,
|
||||
f.Name,
|
||||
driveType(f.MimeType),
|
||||
formatDriveSize(f.Size),
|
||||
formatDateTime(f.ModifiedTime),
|
||||
)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveGetCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "get <fileId>",
|
||||
Short: "Get file metadata",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", f.Id)
|
||||
u.Out().Printf("name\t%s", f.Name)
|
||||
u.Out().Printf("type\t%s", f.MimeType)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(f.Size))
|
||||
u.Out().Printf("created\t%s", f.CreatedTime)
|
||||
u.Out().Printf("modified\t%s", f.ModifiedTime)
|
||||
if f.Description != "" {
|
||||
u.Out().Printf("description\t%s", f.Description)
|
||||
}
|
||||
u.Out().Printf("starred\t%t", f.Starred)
|
||||
if f.WebViewLink != "" {
|
||||
u.Out().Printf("link\t%s", f.WebViewLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveDownloadCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "download <fileId> [destPath]",
|
||||
Short: "Download a file (Google Docs exported)",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := args[0]
|
||||
destPath := ""
|
||||
if len(args) == 2 {
|
||||
destPath = args[1]
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, mimeType").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Name == "" {
|
||||
return errors.New("file has no name")
|
||||
}
|
||||
|
||||
if destPath == "" {
|
||||
dir, dirErr := config.EnsureDriveDownloadsDir()
|
||||
if dirErr != nil {
|
||||
return dirErr
|
||||
}
|
||||
destPath = filepath.Join(dir, fmt.Sprintf("%s_%s", fileID, meta.Name))
|
||||
}
|
||||
|
||||
outPath, size, err := downloadDriveFile(cmd.Context(), svc, meta, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"path": outPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("path\t%s", outPath)
|
||||
u.Out().Printf("size\t%s", formatDriveSize(size))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveUploadCmd(flags *rootFlags) *cobra.Command {
|
||||
var name string
|
||||
var folderID string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload <localPath>",
|
||||
Short: "Upload a file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localPath := args[0]
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fileName := name
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(localPath)
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta := &drive.File{Name: fileName}
|
||||
if folderID != "" {
|
||||
meta.Parents = []string{folderID}
|
||||
}
|
||||
|
||||
mimeType := guessMimeType(localPath)
|
||||
created, err := svc.Files.Create(meta).
|
||||
SupportsAllDrives(true).
|
||||
Media(f, gapi.ContentType(mimeType)).
|
||||
Fields("id, name, mimeType, size, webViewLink").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", created.Id)
|
||||
u.Out().Printf("name\t%s", created.Name)
|
||||
if created.WebViewLink != "" {
|
||||
u.Out().Printf("link\t%s", created.WebViewLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "Override filename")
|
||||
cmd.Flags().StringVar(&folderID, "folder", "", "Destination folder ID")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveMkdirCmd(flags *rootFlags) *cobra.Command {
|
||||
var parent string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "mkdir <name>",
|
||||
Short: "Create a folder",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f := &drive.File{
|
||||
Name: name,
|
||||
MimeType: "application/vnd.google-apps.folder",
|
||||
}
|
||||
if parent != "" {
|
||||
f.Parents = []string{parent}
|
||||
}
|
||||
|
||||
created, err := svc.Files.Create(f).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, webViewLink").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"folder": created})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", created.Id)
|
||||
u.Out().Printf("name\t%s", created.Name)
|
||||
if created.WebViewLink != "" {
|
||||
u.Out().Printf("link\t%s", created.WebViewLink)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&parent, "parent", "", "Parent folder ID")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveDeleteCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <fileId>",
|
||||
Short: "Delete a file (moves to trash)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"deleted": true,
|
||||
"id": fileID,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("id\t%s", fileID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveMoveCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "move <fileId> <newParentId>",
|
||||
Short: "Move a file to a different folder",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
newParentID := args[1]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
meta, err := svc.Files.Get(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name, parents").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
call := svc.Files.Update(fileID, &drive.File{}).
|
||||
SupportsAllDrives(true).
|
||||
AddParents(newParentID).
|
||||
Fields("id, name, parents, webViewLink")
|
||||
if len(meta.Parents) > 0 {
|
||||
call = call.RemoveParents(strings.Join(meta.Parents, ","))
|
||||
}
|
||||
|
||||
updated, err := call.Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", updated.Id)
|
||||
u.Out().Printf("name\t%s", updated.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveRenameCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename <fileId> <newName>",
|
||||
Short: "Rename a file or folder",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
newName := args[1]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := svc.Files.Update(fileID, &drive.File{Name: newName}).
|
||||
SupportsAllDrives(true).
|
||||
Fields("id, name").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
|
||||
}
|
||||
|
||||
u.Out().Printf("id\t%s", updated.Id)
|
||||
u.Out().Printf("name\t%s", updated.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveShareCmd(flags *rootFlags) *cobra.Command {
|
||||
var anyone bool
|
||||
var email string
|
||||
var role string
|
||||
var discoverable bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "share <fileId>",
|
||||
Short: "Share a file or folder",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
|
||||
if !anyone && email == "" {
|
||||
return errors.New("must specify --anyone or --email")
|
||||
}
|
||||
if role == "" {
|
||||
role = "reader"
|
||||
}
|
||||
if role != "reader" && role != "writer" {
|
||||
return errors.New("invalid --role (expected reader|writer)")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perm := &drive.Permission{Role: role}
|
||||
if anyone {
|
||||
perm.Type = "anyone"
|
||||
perm.AllowFileDiscovery = discoverable
|
||||
} else {
|
||||
perm.Type = "user"
|
||||
perm.EmailAddress = email
|
||||
}
|
||||
|
||||
created, err := svc.Permissions.Create(fileID, perm).
|
||||
SupportsAllDrives(true).
|
||||
SendNotificationEmail(false).
|
||||
Fields("id, type, role, emailAddress").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link, err := driveWebLink(svc, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"link": link,
|
||||
"permissionId": created.Id,
|
||||
"permission": created,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("link\t%s", link)
|
||||
u.Out().Printf("permission_id\t%s", created.Id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&anyone, "anyone", false, "Make publicly accessible")
|
||||
cmd.Flags().StringVar(&email, "email", "", "Share with specific user")
|
||||
cmd.Flags().StringVar(&role, "role", "reader", "Permission: reader|writer")
|
||||
cmd.Flags().BoolVar(&discoverable, "discoverable", false, "Allow file discovery in search (anyone/domain only)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveUnshareCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "unshare <fileId> <permissionId>",
|
||||
Short: "Remove a permission from a file",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
permissionID := args[1]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"removed": true,
|
||||
"fileId": fileID,
|
||||
"permissionId": permissionID,
|
||||
})
|
||||
}
|
||||
|
||||
u.Out().Printf("removed\ttrue")
|
||||
u.Out().Printf("file_id\t%s", fileID)
|
||||
u.Out().Printf("permission_id\t%s", permissionID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDrivePermissionsCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "permissions <fileId>",
|
||||
Short: "List permissions on a file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := args[0]
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Permissions.List(fileID).
|
||||
SupportsAllDrives(true).
|
||||
Fields("permissions(id, type, role, emailAddress)").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"permissions": resp.Permissions})
|
||||
}
|
||||
if len(resp.Permissions) == 0 {
|
||||
u.Err().Println("No permissions")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tTYPE\tROLE\tEMAIL")
|
||||
for _, p := range resp.Permissions {
|
||||
email := p.EmailAddress
|
||||
if email == "" {
|
||||
email = "-"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", p.Id, p.Type, p.Role, email)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDriveURLCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "url <fileIds...>",
|
||||
Short: "Print web URLs for files",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewDrive(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range args {
|
||||
link, err := driveWebLink(svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
// collected below
|
||||
} else {
|
||||
u.Out().Printf("%s\t%s", id, link)
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
urls := make([]map[string]string, 0, len(args))
|
||||
for _, id := range args {
|
||||
link, err := driveWebLink(svc, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
urls = append(urls, map[string]string{"id": id, "url": link})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveListQuery(folderID string, userQuery string) string {
|
||||
q := strings.TrimSpace(userQuery)
|
||||
parent := fmt.Sprintf("'%s' in parents", folderID)
|
||||
if q != "" {
|
||||
q = q + " and " + parent
|
||||
} else {
|
||||
q = parent
|
||||
}
|
||||
if !strings.Contains(q, "trashed") {
|
||||
q = q + " and trashed = false"
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func buildDriveSearchQuery(text string) string {
|
||||
q := fmt.Sprintf("fullText contains '%s'", escapeDriveQueryString(text))
|
||||
return q + " and trashed = false"
|
||||
}
|
||||
|
||||
func escapeDriveQueryString(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "\\'")
|
||||
}
|
||||
|
||||
func driveType(mimeType string) string {
|
||||
if mimeType == "application/vnd.google-apps.folder" {
|
||||
return "folder"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
func formatDateTime(iso string) string {
|
||||
if iso == "" {
|
||||
return "-"
|
||||
}
|
||||
if len(iso) >= 16 {
|
||||
return strings.ReplaceAll(iso[:16], "T", " ")
|
||||
}
|
||||
return iso
|
||||
}
|
||||
|
||||
func formatDriveSize(bytes int64) string {
|
||||
if bytes <= 0 {
|
||||
return "-"
|
||||
}
|
||||
const unit = 1024.0
|
||||
b := float64(bytes)
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
i := 0
|
||||
for b >= unit && i < len(units)-1 {
|
||||
b /= unit
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", b, units[i])
|
||||
}
|
||||
|
||||
func guessMimeType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".doc":
|
||||
return "application/msword"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint"
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".html":
|
||||
return "text/html"
|
||||
case ".css":
|
||||
return "text/css"
|
||||
case ".js":
|
||||
return "application/javascript"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
case ".md":
|
||||
return "text/markdown"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string) (string, int64, error) {
|
||||
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
|
||||
|
||||
var (
|
||||
resp *http.Response
|
||||
outPath string
|
||||
err error
|
||||
)
|
||||
|
||||
if isGoogleDoc {
|
||||
exportMimeType := driveExportMimeType(meta.MimeType)
|
||||
outPath = replaceExt(destPath, driveExportExtension(exportMimeType))
|
||||
resp, err = svc.Files.Export(meta.Id, exportMimeType).Context(ctx).Download()
|
||||
} else {
|
||||
outPath = destPath
|
||||
resp, err = svc.Files.Get(meta.Id).SupportsAllDrives(true).Context(ctx).Download()
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return outPath, n, nil
|
||||
}
|
||||
|
||||
func replaceExt(path string, ext string) string {
|
||||
base := strings.TrimSuffix(path, filepath.Ext(path))
|
||||
return base + ext
|
||||
}
|
||||
|
||||
func driveExportMimeType(googleMimeType string) string {
|
||||
switch googleMimeType {
|
||||
case "application/vnd.google-apps.document":
|
||||
return "application/pdf"
|
||||
case "application/vnd.google-apps.spreadsheet":
|
||||
return "text/csv"
|
||||
case "application/vnd.google-apps.presentation":
|
||||
return "application/pdf"
|
||||
case "application/vnd.google-apps.drawing":
|
||||
return "image/png"
|
||||
default:
|
||||
return "application/pdf"
|
||||
}
|
||||
}
|
||||
|
||||
func driveExportExtension(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "application/pdf":
|
||||
return ".pdf"
|
||||
case "text/csv":
|
||||
return ".csv"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "text/plain":
|
||||
return ".txt"
|
||||
default:
|
||||
return ".pdf"
|
||||
}
|
||||
}
|
||||
|
||||
func driveWebLink(svc *drive.Service, fileID string) (string, error) {
|
||||
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Do()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.WebViewLink != "" {
|
||||
return f.WebViewLink, nil
|
||||
}
|
||||
return fmt.Sprintf("https://drive.google.com/file/d/%s/view", fileID), nil
|
||||
}
|
||||
52
internal/cmd/drive_test.go
Normal file
52
internal/cmd/drive_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDriveListQuery(t *testing.T) {
|
||||
t.Run("adds parent and trashed", func(t *testing.T) {
|
||||
got := buildDriveListQuery("root", "")
|
||||
if got != "'root' in parents and trashed = false" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combines with user query", func(t *testing.T) {
|
||||
got := buildDriveListQuery("abc", "mimeType='image/png'")
|
||||
if got != "mimeType='image/png' and 'abc' in parents and trashed = false" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not force trashed when user sets it", func(t *testing.T) {
|
||||
got := buildDriveListQuery("abc", "trashed = true")
|
||||
if got != "trashed = true and 'abc' in parents" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDriveSearchQuery(t *testing.T) {
|
||||
got := buildDriveSearchQuery("hello world")
|
||||
if got != "fullText contains 'hello world' and trashed = false" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeDriveQueryString(t *testing.T) {
|
||||
got := escapeDriveQueryString("a'b")
|
||||
if got != "a\\'b" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDriveSize(t *testing.T) {
|
||||
if got := formatDriveSize(0); got != "-" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := formatDriveSize(1); got != "1 B" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := formatDriveSize(1024); got != "1.0 KB" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
186
internal/cmd/gmail.go
Normal file
186
internal/cmd/gmail.go
Normal file
@ -0,0 +1,186 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func newGmailCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "gmail",
|
||||
Short: "Gmail",
|
||||
}
|
||||
cmd.AddCommand(newGmailSearchCmd(flags))
|
||||
cmd.AddCommand(newGmailThreadCmd(flags))
|
||||
cmd.AddCommand(newGmailURLCmd(flags))
|
||||
cmd.AddCommand(newGmailLabelsCmd(flags))
|
||||
cmd.AddCommand(newGmailSendCmd(flags))
|
||||
cmd.AddCommand(newGmailDraftsCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search threads using Gmail query syntax",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Users.Threads.List("me").
|
||||
Q(query).
|
||||
MaxResults(max).
|
||||
PageToken(page).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idToName, err := fetchLabelIDToName(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.Threads))
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
for _, t := range resp.Threads {
|
||||
if t.Id == "" {
|
||||
continue
|
||||
}
|
||||
thread, err := svc.Users.Threads.Get("me", t.Id).
|
||||
Format("metadata").
|
||||
MetadataHeaders("From", "Subject", "Date").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := firstMessage(thread)
|
||||
date := ""
|
||||
from := ""
|
||||
subject := ""
|
||||
var labels []string
|
||||
if msg != nil {
|
||||
date = formatGmailDate(headerValue(msg.Payload, "Date"))
|
||||
from = headerValue(msg.Payload, "From")
|
||||
subject = headerValue(msg.Payload, "Subject")
|
||||
if len(msg.LabelIds) > 0 {
|
||||
names := make([]string, 0, len(msg.LabelIds))
|
||||
for _, id := range msg.LabelIds {
|
||||
if n, ok := idToName[id]; ok {
|
||||
names = append(names, n)
|
||||
} else {
|
||||
names = append(names, id)
|
||||
}
|
||||
}
|
||||
labels = names
|
||||
}
|
||||
}
|
||||
|
||||
items = append(items, item{
|
||||
ID: t.Id,
|
||||
Date: date,
|
||||
From: sanitizeTab(from),
|
||||
Subject: sanitizeTab(subject),
|
||||
Labels: labels,
|
||||
})
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"threads": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
u.Err().Println("No results")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(tw, "ID\tDATE\tFROM\tSUBJECT\tLABELS")
|
||||
for _, it := range items {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject, strings.Join(it.Labels, ","))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 10, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func firstMessage(t *gmail.Thread) *gmail.Message {
|
||||
if t == nil || len(t.Messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
return t.Messages[0]
|
||||
}
|
||||
|
||||
func headerValue(p *gmail.MessagePart, name string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
for _, h := range p.Headers {
|
||||
if strings.EqualFold(h.Name, name) {
|
||||
return h.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatGmailDate(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := mailParseDate(raw); err == nil {
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func sanitizeTab(s string) string {
|
||||
return strings.ReplaceAll(s, "\t", " ")
|
||||
}
|
||||
|
||||
func mailParseDate(s string) (time.Time, error) {
|
||||
// net/mail has the most compatible Date parser, but we keep this isolated for easier tests/mocks later.
|
||||
return mail.ParseDate(s)
|
||||
}
|
||||
379
internal/cmd/gmail_drafts.go
Normal file
379
internal/cmd/gmail_drafts.go
Normal file
@ -0,0 +1,379 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func newGmailDraftsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "drafts",
|
||||
Short: "Manage drafts",
|
||||
}
|
||||
cmd.AddCommand(newGmailDraftsListCmd(flags))
|
||||
cmd.AddCommand(newGmailDraftsGetCmd(flags))
|
||||
cmd.AddCommand(newGmailDraftsDeleteCmd(flags))
|
||||
cmd.AddCommand(newGmailDraftsSendCmd(flags))
|
||||
cmd.AddCommand(newGmailDraftsCreateCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailDraftsListCmd(flags *rootFlags) *cobra.Command {
|
||||
var max int64
|
||||
var page string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List drafts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Users.Drafts.List("me").MaxResults(max).PageToken(page).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
ThreadID string `json:"threadId,omitempty"`
|
||||
}
|
||||
items := make([]item, 0, len(resp.Drafts))
|
||||
for _, d := range resp.Drafts {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
var msgID, threadID string
|
||||
if d.Message != nil {
|
||||
msgID = d.Message.Id
|
||||
threadID = d.Message.ThreadId
|
||||
}
|
||||
items = append(items, item{ID: d.Id, MessageID: msgID, ThreadID: threadID})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"drafts": items,
|
||||
"nextPageToken": resp.NextPageToken,
|
||||
})
|
||||
}
|
||||
if len(resp.Drafts) == 0 {
|
||||
u.Err().Println("No drafts")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tMESSAGE_ID")
|
||||
for _, d := range resp.Drafts {
|
||||
msgID := ""
|
||||
if d.Message != nil {
|
||||
msgID = d.Message.Id
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\n", d.Id, msgID)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
|
||||
if resp.NextPageToken != "" {
|
||||
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Int64Var(&max, "max", 20, "Max results")
|
||||
cmd.Flags().StringVar(&page, "page", "", "Page token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailDraftsGetCmd(flags *rootFlags) *cobra.Command {
|
||||
var download bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <draftId>",
|
||||
Short: "Get draft details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draftID := args[0]
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
draft, err := svc.Users.Drafts.Get("me", draftID).Format("full").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Message == nil {
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"draft": draft})
|
||||
}
|
||||
u.Err().Println("Empty draft")
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := draft.Message
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
out := map[string]any{"draft": draft}
|
||||
if download {
|
||||
attachDir, err := config.EnsureGmailAttachmentsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type dl struct {
|
||||
MessageID string `json:"messageId"`
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
Cached bool `json:"cached"`
|
||||
}
|
||||
downloaded := make([]dl, 0)
|
||||
for _, a := range collectAttachments(msg.Payload) {
|
||||
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
downloaded = append(downloaded, dl{
|
||||
MessageID: msg.Id,
|
||||
AttachmentID: a.AttachmentID,
|
||||
Filename: a.Filename,
|
||||
Path: outPath,
|
||||
Cached: cached,
|
||||
})
|
||||
}
|
||||
out["downloaded"] = downloaded
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, out)
|
||||
}
|
||||
|
||||
u.Out().Printf("Draft-ID: %s", draft.Id)
|
||||
u.Out().Printf("Message-ID: %s", msg.Id)
|
||||
u.Out().Printf("To: %s", headerValue(msg.Payload, "To"))
|
||||
u.Out().Printf("Cc: %s", headerValue(msg.Payload, "Cc"))
|
||||
u.Out().Printf("Subject: %s", headerValue(msg.Payload, "Subject"))
|
||||
u.Out().Println("")
|
||||
|
||||
body := bestBodyText(msg.Payload)
|
||||
if body != "" {
|
||||
u.Out().Println(body)
|
||||
u.Out().Println("")
|
||||
}
|
||||
|
||||
attachments := collectAttachments(msg.Payload)
|
||||
if len(attachments) > 0 {
|
||||
u.Out().Println("Attachments:")
|
||||
for _, a := range attachments {
|
||||
u.Out().Printf(" - %s (%d bytes)", a.Filename, a.Size)
|
||||
}
|
||||
u.Out().Println("")
|
||||
}
|
||||
|
||||
if download && msg.Id != "" && len(attachments) > 0 {
|
||||
attachDir, err := config.EnsureGmailAttachmentsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range attachments {
|
||||
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached {
|
||||
u.Out().Printf("Cached: %s", outPath)
|
||||
} else {
|
||||
u.Out().Successf("Saved: %s", outPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&download, "download", false, "Download draft attachments")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailDraftsDeleteCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <draftId>",
|
||||
Short: "Delete a draft",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draftID := args[0]
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.Users.Drafts.Delete("me", draftID).Do(); err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"deleted": true, "draftId": draftID})
|
||||
}
|
||||
u.Out().Printf("deleted\ttrue")
|
||||
u.Out().Printf("draft_id\t%s", draftID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGmailDraftsSendCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "send <draftId>",
|
||||
Short: "Send a draft",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draftID := args[0]
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, err := svc.Users.Drafts.Send("me", &gmail.Draft{Id: draftID}).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"messageId": msg.Id,
|
||||
"threadId": msg.ThreadId,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("message_id\t%s", msg.Id)
|
||||
if msg.ThreadId != "" {
|
||||
u.Out().Printf("thread_id\t%s", msg.ThreadId)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
var cc string
|
||||
var bcc string
|
||||
var subject string
|
||||
var body string
|
||||
var replyTo string
|
||||
var attach []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a draft",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(body) == "" {
|
||||
return errors.New("required: --to, --subject, --body")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyTo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
atts := make([]mailAttachment, 0, len(attach))
|
||||
for _, p := range attach {
|
||||
atts = append(atts, mailAttachment{Path: p})
|
||||
}
|
||||
|
||||
raw, err := buildRFC822(mailOptions{
|
||||
From: account,
|
||||
To: splitCSV(to),
|
||||
Cc: splitCSV(cc),
|
||||
Bcc: splitCSV(bcc),
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
Attachments: atts,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := &gmail.Message{
|
||||
Raw: base64.RawURLEncoding.EncodeToString(raw),
|
||||
}
|
||||
if threadID != "" {
|
||||
msg.ThreadId = threadID
|
||||
}
|
||||
|
||||
draft, err := svc.Users.Drafts.Create("me", &gmail.Draft{Message: msg}).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"draftId": draft.Id,
|
||||
"message": draft.Message,
|
||||
"threadId": threadID,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("draft_id\t%s", draft.Id)
|
||||
if draft.Message != nil && draft.Message.Id != "" {
|
||||
u.Out().Printf("message_id\t%s", draft.Message.Id)
|
||||
}
|
||||
if threadID != "" {
|
||||
u.Out().Printf("thread_id\t%s", threadID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipients (comma-separated, required)")
|
||||
cmd.Flags().StringVar(&cc, "cc", "", "CC recipients (comma-separated)")
|
||||
cmd.Flags().StringVar(&bcc, "bcc", "", "BCC recipients (comma-separated)")
|
||||
cmd.Flags().StringVar(&subject, "subject", "", "Subject (required)")
|
||||
cmd.Flags().StringVar(&body, "body", "", "Body (required)")
|
||||
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply to message ID (sets In-Reply-To/References and thread)")
|
||||
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
192
internal/cmd/gmail_labels.go
Normal file
192
internal/cmd/gmail_labels.go
Normal file
@ -0,0 +1,192 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func newGmailLabelsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "labels",
|
||||
Short: "List and modify labels",
|
||||
}
|
||||
|
||||
cmd.AddCommand(newGmailLabelsListCmd(flags))
|
||||
cmd.AddCommand(newGmailLabelsModifyCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailLabelsListCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List labels",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := svc.Users.Labels.List("me").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"labels": resp.Labels})
|
||||
}
|
||||
if len(resp.Labels) == 0 {
|
||||
u.Err().Println("No labels")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tTYPE")
|
||||
for _, l := range resp.Labels {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", l.Id, l.Name, l.Type)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGmailLabelsModifyCmd(flags *rootFlags) *cobra.Command {
|
||||
var add string
|
||||
var remove string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "modify <threadIds...>",
|
||||
Short: "Modify labels on threads",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threadIDs := args
|
||||
addLabels := splitCSV(add)
|
||||
removeLabels := splitCSV(remove)
|
||||
if len(addLabels) == 0 && len(removeLabels) == 0 {
|
||||
return errors.New("must specify --add and/or --remove")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idMap, err := fetchLabelNameToID(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addIDs := resolveLabelIDs(addLabels, idMap)
|
||||
removeIDs := resolveLabelIDs(removeLabels, idMap)
|
||||
|
||||
type result struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
results := make([]result, 0, len(threadIDs))
|
||||
|
||||
for _, tid := range threadIDs {
|
||||
_, err := svc.Users.Threads.Modify("me", tid, &gmail.ModifyThreadRequest{
|
||||
AddLabelIds: addIDs,
|
||||
RemoveLabelIds: removeIDs,
|
||||
}).Do()
|
||||
if err != nil {
|
||||
results = append(results, result{ThreadID: tid, Success: false, Error: err.Error()})
|
||||
if !outfmt.IsJSON(cmd.Context()) {
|
||||
u.Err().Errorf("%s: %s", tid, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
results = append(results, result{ThreadID: tid, Success: true})
|
||||
if !outfmt.IsJSON(cmd.Context()) {
|
||||
u.Out().Printf("%s\tok", tid)
|
||||
}
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"results": results})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&add, "add", "", "Labels to add (comma-separated, name or ID)")
|
||||
cmd.Flags().StringVar(&remove, "remove", "", "Labels to remove (comma-separated, name or ID)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func fetchLabelNameToID(svc *gmail.Service) (map[string]string, error) {
|
||||
resp, err := svc.Users.Labels.List("me").Do()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]string, len(resp.Labels))
|
||||
for _, l := range resp.Labels {
|
||||
if l.Id == "" {
|
||||
continue
|
||||
}
|
||||
m[strings.ToLower(l.Id)] = l.Id
|
||||
if l.Name != "" {
|
||||
m[strings.ToLower(l.Name)] = l.Id
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func fetchLabelIDToName(svc *gmail.Service) (map[string]string, error) {
|
||||
resp, err := svc.Users.Labels.List("me").Do()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]string, len(resp.Labels))
|
||||
for _, l := range resp.Labels {
|
||||
if l.Id == "" {
|
||||
continue
|
||||
}
|
||||
if l.Name != "" {
|
||||
m[l.Id] = l.Name
|
||||
} else {
|
||||
m[l.Id] = l.Id
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func resolveLabelIDs(values []string, nameToID map[string]string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := nameToID[strings.ToLower(v)]; ok {
|
||||
out = append(out, id)
|
||||
} else {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
23
internal/cmd/gmail_labels_test.go
Normal file
23
internal/cmd/gmail_labels_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveLabelIDs(t *testing.T) {
|
||||
m := map[string]string{
|
||||
"inbox": "INBOX",
|
||||
"custom": "Label_123",
|
||||
}
|
||||
got := resolveLabelIDs([]string{"INBOX", "custom", "Label_999"}, m)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
if got[0] != "INBOX" || got[1] != "Label_123" || got[2] != "Label_999" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLabelIDToNameBehavior(t *testing.T) {
|
||||
// Unit tests for the actual API call live in integration; here we just ensure
|
||||
// the helper exists and returns a map. (Compile-time coverage.)
|
||||
_ = fetchLabelIDToName
|
||||
}
|
||||
222
internal/cmd/gmail_mime.go
Normal file
222
internal/cmd/gmail_mime.go
Normal file
@ -0,0 +1,222 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mailAttachment struct {
|
||||
Path string
|
||||
Filename string
|
||||
MIMEType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type mailOptions struct {
|
||||
From string
|
||||
To []string
|
||||
Cc []string
|
||||
Bcc []string
|
||||
Subject string
|
||||
Body string
|
||||
InReplyTo string
|
||||
References string
|
||||
AdditionalHeaders map[string]string
|
||||
Attachments []mailAttachment
|
||||
}
|
||||
|
||||
func buildRFC822(opts mailOptions) ([]byte, error) {
|
||||
if strings.TrimSpace(opts.From) == "" {
|
||||
return nil, errors.New("missing From")
|
||||
}
|
||||
if len(opts.To) == 0 {
|
||||
return nil, errors.New("missing To")
|
||||
}
|
||||
if strings.TrimSpace(opts.Subject) == "" {
|
||||
return nil, errors.New("missing Subject")
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
|
||||
if err := validateHeaderValue(opts.From); err != nil {
|
||||
return nil, fmt.Errorf("invalid From: %w", err)
|
||||
}
|
||||
for _, a := range append(append([]string{}, opts.To...), append(opts.Cc, opts.Bcc...)...) {
|
||||
if err := validateHeaderValue(a); err != nil {
|
||||
return nil, fmt.Errorf("invalid address: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeHeader(&b, "From", opts.From)
|
||||
writeHeader(&b, "To", strings.Join(opts.To, ", "))
|
||||
if len(opts.Cc) > 0 {
|
||||
writeHeader(&b, "Cc", strings.Join(opts.Cc, ", "))
|
||||
}
|
||||
if len(opts.Bcc) > 0 {
|
||||
writeHeader(&b, "Bcc", strings.Join(opts.Bcc, ", "))
|
||||
}
|
||||
if err := validateHeaderValue(opts.Subject); err != nil {
|
||||
return nil, fmt.Errorf("invalid Subject: %w", err)
|
||||
}
|
||||
writeHeader(&b, "Subject", encodeHeaderIfNeeded(opts.Subject))
|
||||
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
|
||||
writeHeader(&b, "MIME-Version", "1.0")
|
||||
if strings.TrimSpace(opts.InReplyTo) != "" {
|
||||
if err := validateHeaderValue(opts.InReplyTo); err != nil {
|
||||
return nil, fmt.Errorf("invalid In-Reply-To: %w", err)
|
||||
}
|
||||
writeHeader(&b, "In-Reply-To", strings.TrimSpace(opts.InReplyTo))
|
||||
}
|
||||
if strings.TrimSpace(opts.References) != "" {
|
||||
if err := validateHeaderValue(opts.References); err != nil {
|
||||
return nil, fmt.Errorf("invalid References: %w", err)
|
||||
}
|
||||
writeHeader(&b, "References", strings.TrimSpace(opts.References))
|
||||
}
|
||||
for k, v := range opts.AdditionalHeaders {
|
||||
if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" {
|
||||
if err := validateHeaderValue(v); err != nil {
|
||||
return nil, fmt.Errorf("invalid header %s: %w", k, err)
|
||||
}
|
||||
writeHeader(&b, k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if len(opts.Attachments) == 0 {
|
||||
writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"")
|
||||
writeHeader(&b, "Content-Transfer-Encoding", "7bit")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(opts.Body)
|
||||
if !strings.HasSuffix(opts.Body, "\r\n") {
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
boundary, err := randomBoundary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q", boundary))
|
||||
b.WriteString("\r\n")
|
||||
|
||||
// Body part
|
||||
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
b.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
|
||||
b.WriteString(opts.Body)
|
||||
if !strings.HasSuffix(opts.Body, "\r\n") {
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for _, a := range opts.Attachments {
|
||||
if a.Filename == "" {
|
||||
a.Filename = filepath.Base(a.Path)
|
||||
}
|
||||
if a.MIMEType == "" {
|
||||
a.MIMEType = mime.TypeByExtension(strings.ToLower(filepath.Ext(a.Filename)))
|
||||
if a.MIMEType == "" {
|
||||
a.MIMEType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
if len(a.Data) == 0 {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Data = data
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||
b.WriteString(fmt.Sprintf("Content-Type: %s\r\n", a.MIMEType))
|
||||
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
b.WriteString(fmt.Sprintf("Content-Disposition: attachment; %s\r\n\r\n", contentDispositionFilename(a.Filename)))
|
||||
b.WriteString(wrapBase64(a.Data))
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeHeader(b *bytes.Buffer, name, value string) {
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(value)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func wrapBase64(b []byte) string {
|
||||
s := base64.StdEncoding.EncodeToString(b)
|
||||
const width = 76
|
||||
var out strings.Builder
|
||||
for len(s) > width {
|
||||
out.WriteString(s[:width])
|
||||
out.WriteString("\r\n")
|
||||
s = s[width:]
|
||||
}
|
||||
if len(s) > 0 {
|
||||
out.WriteString(s)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func randomBoundary() (string, error) {
|
||||
var b [18]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gogcli_" + base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func validateHeaderValue(v string) error {
|
||||
if strings.Contains(v, "\r") || strings.Contains(v, "\n") {
|
||||
return errors.New("header value contains newline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeHeaderIfNeeded(v string) string {
|
||||
if isASCII(v) {
|
||||
return v
|
||||
}
|
||||
return mime.QEncoding.Encode("utf-8", v)
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 0x80 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contentDispositionFilename(filename string) string {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return `filename="attachment"`
|
||||
}
|
||||
if isASCII(filename) {
|
||||
return fmt.Sprintf("filename=%q", filename)
|
||||
}
|
||||
// RFC 5987 / RFC 2231 style.
|
||||
return "filename*=UTF-8''" + rfc5987Encode(filename)
|
||||
}
|
||||
|
||||
func rfc5987Encode(s string) string {
|
||||
// url.QueryEscape uses '+' for spaces; RFC 5987 wants %20.
|
||||
esc := url.QueryEscape(s)
|
||||
return strings.ReplaceAll(esc, "+", "%20")
|
||||
}
|
||||
67
internal/cmd/gmail_mime_test.go
Normal file
67
internal/cmd/gmail_mime_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRFC822Plain(t *testing.T) {
|
||||
raw, err := buildRFC822(mailOptions{
|
||||
From: "a@b.com",
|
||||
To: []string{"c@d.com"},
|
||||
Subject: "Hi",
|
||||
Body: "Hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, "Content-Type: text/plain") {
|
||||
t.Fatalf("missing content-type: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "\r\n\r\nHello\r\n") {
|
||||
t.Fatalf("missing body: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRFC822WithAttachment(t *testing.T) {
|
||||
raw, err := buildRFC822(mailOptions{
|
||||
From: "a@b.com",
|
||||
To: []string{"c@d.com"},
|
||||
Subject: "Hi",
|
||||
Body: "Hello",
|
||||
Attachments: []mailAttachment{
|
||||
{Filename: "x.txt", MIMEType: "text/plain", Data: []byte("abc")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, "multipart/mixed") {
|
||||
t.Fatalf("expected multipart: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "Content-Disposition: attachment; filename=\"x.txt\"") {
|
||||
t.Fatalf("missing attachment header: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeHeaderIfNeeded(t *testing.T) {
|
||||
if got := encodeHeaderIfNeeded("Hello"); got != "Hello" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
got := encodeHeaderIfNeeded("Grüße")
|
||||
if got == "Grüße" || !strings.Contains(got, "=?utf-8?") {
|
||||
t.Fatalf("expected encoded-word, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentDispositionFilename(t *testing.T) {
|
||||
if got := contentDispositionFilename("a.txt"); got != "filename=\"a.txt\"" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
got := contentDispositionFilename("Grüße.txt")
|
||||
if !strings.HasPrefix(got, "filename*=UTF-8''") {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
131
internal/cmd/gmail_send.go
Normal file
131
internal/cmd/gmail_send.go
Normal file
@ -0,0 +1,131 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func newGmailSendCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
var cc string
|
||||
var bcc string
|
||||
var subject string
|
||||
var body string
|
||||
var replyTo string
|
||||
var attach []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "Send an email",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" || strings.TrimSpace(body) == "" {
|
||||
return errors.New("required: --to, --subject, --body")
|
||||
}
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyTo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
atts := make([]mailAttachment, 0, len(attach))
|
||||
for _, p := range attach {
|
||||
atts = append(atts, mailAttachment{Path: p})
|
||||
}
|
||||
|
||||
raw, err := buildRFC822(mailOptions{
|
||||
From: account,
|
||||
To: splitCSV(to),
|
||||
Cc: splitCSV(cc),
|
||||
Bcc: splitCSV(bcc),
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
Attachments: atts,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := &gmail.Message{
|
||||
Raw: base64.RawURLEncoding.EncodeToString(raw),
|
||||
}
|
||||
if threadID != "" {
|
||||
msg.ThreadId = threadID
|
||||
}
|
||||
|
||||
sent, err := svc.Users.Messages.Send("me", msg).Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"messageId": sent.Id,
|
||||
"threadId": sent.ThreadId,
|
||||
})
|
||||
}
|
||||
u.Out().Printf("message_id\t%s", sent.Id)
|
||||
if sent.ThreadId != "" {
|
||||
u.Out().Printf("thread_id\t%s", sent.ThreadId)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&to, "to", "", "Recipients (comma-separated, required)")
|
||||
cmd.Flags().StringVar(&cc, "cc", "", "CC recipients (comma-separated)")
|
||||
cmd.Flags().StringVar(&bcc, "bcc", "", "BCC recipients (comma-separated)")
|
||||
cmd.Flags().StringVar(&subject, "subject", "", "Subject (required)")
|
||||
cmd.Flags().StringVar(&body, "body", "", "Body (required)")
|
||||
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply to message ID (sets In-Reply-To/References and thread)")
|
||||
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func replyHeaders(cmd *cobra.Command, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) {
|
||||
replyToMessageID = strings.TrimSpace(replyToMessageID)
|
||||
if replyToMessageID == "" {
|
||||
return "", "", "", nil
|
||||
}
|
||||
msg, err := svc.Users.Messages.Get("me", replyToMessageID).
|
||||
Format("metadata").
|
||||
MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To").
|
||||
Context(cmd.Context()).
|
||||
Do()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
threadID = msg.ThreadId
|
||||
// Prefer Message-ID and References from the original message.
|
||||
messageID := headerValue(msg.Payload, "Message-ID")
|
||||
if messageID == "" {
|
||||
messageID = headerValue(msg.Payload, "Message-Id")
|
||||
}
|
||||
inReplyTo = messageID
|
||||
references = strings.TrimSpace(headerValue(msg.Payload, "References"))
|
||||
if references == "" {
|
||||
references = messageID
|
||||
} else if messageID != "" && !strings.Contains(references, messageID) {
|
||||
references = references + " " + messageID
|
||||
}
|
||||
return inReplyTo, references, threadID, nil
|
||||
}
|
||||
41
internal/cmd/gmail_test.go
Normal file
41
internal/cmd/gmail_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func TestHeaderValue(t *testing.T) {
|
||||
p := &gmail.MessagePart{
|
||||
Headers: []*gmail.MessagePartHeader{
|
||||
{Name: "From", Value: "a@example.com"},
|
||||
{Name: "Subject", Value: "Hello"},
|
||||
},
|
||||
}
|
||||
if got := headerValue(p, "from"); got != "a@example.com" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := headerValue(p, "subject"); got != "Hello" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := headerValue(p, "date"); got != "" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTab(t *testing.T) {
|
||||
if got := sanitizeTab("a\tb"); got != "a b" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGmailDate(t *testing.T) {
|
||||
got := formatGmailDate("Mon, 02 Jan 2006 15:04:05 -0700")
|
||||
if got != "2006-01-02 15:04" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
if got := formatGmailDate("not a date"); got != "not a date" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
276
internal/cmd/gmail_thread.go
Normal file
276
internal/cmd/gmail_thread.go
Normal file
@ -0,0 +1,276 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
"google.golang.org/api/gmail/v1"
|
||||
)
|
||||
|
||||
func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
|
||||
var download bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "thread <threadId>",
|
||||
Short: "Get a thread with all messages (optionally download attachments)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threadID := args[0]
|
||||
|
||||
svc, err := googleapi.NewGmail(cmd.Context(), account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thread, err := svc.Users.Threads.Get("me", threadID).Format("full").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
type downloaded struct {
|
||||
MessageID string `json:"messageId"`
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Cached bool `json:"cached"`
|
||||
DownloadError string `json:"error,omitempty"`
|
||||
}
|
||||
downloadedFiles := make([]downloaded, 0)
|
||||
if download && thread != nil {
|
||||
d, err := config.EnsureGmailAttachmentsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, msg := range thread.Messages {
|
||||
if msg == nil || msg.Id == "" {
|
||||
continue
|
||||
}
|
||||
for _, a := range collectAttachments(msg.Payload) {
|
||||
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
df := downloaded{
|
||||
MessageID: msg.Id,
|
||||
AttachmentID: a.AttachmentID,
|
||||
Filename: a.Filename,
|
||||
MimeType: a.MimeType,
|
||||
Size: a.Size,
|
||||
Path: outPath,
|
||||
Cached: cached,
|
||||
}
|
||||
downloadedFiles = append(downloadedFiles, df)
|
||||
}
|
||||
}
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
||||
"thread": thread,
|
||||
"downloaded": downloadedFiles,
|
||||
})
|
||||
}
|
||||
if thread == nil || len(thread.Messages) == 0 {
|
||||
u.Err().Println("Empty thread")
|
||||
return nil
|
||||
}
|
||||
|
||||
var attachDir string
|
||||
if download {
|
||||
d, err := config.EnsureGmailAttachmentsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachDir = d
|
||||
}
|
||||
|
||||
for _, msg := range thread.Messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
u.Out().Printf("Message: %s", msg.Id)
|
||||
u.Out().Printf("From: %s", headerValue(msg.Payload, "From"))
|
||||
u.Out().Printf("To: %s", headerValue(msg.Payload, "To"))
|
||||
u.Out().Printf("Subject: %s", headerValue(msg.Payload, "Subject"))
|
||||
u.Out().Printf("Date: %s", headerValue(msg.Payload, "Date"))
|
||||
u.Out().Println("")
|
||||
|
||||
body := bestBodyText(msg.Payload)
|
||||
if body != "" {
|
||||
u.Out().Println(body)
|
||||
u.Out().Println("")
|
||||
}
|
||||
|
||||
attachments := collectAttachments(msg.Payload)
|
||||
if len(attachments) > 0 {
|
||||
u.Out().Println("Attachments:")
|
||||
for _, a := range attachments {
|
||||
u.Out().Printf(" - %s (%d bytes)", a.Filename, a.Size)
|
||||
}
|
||||
u.Out().Println("")
|
||||
}
|
||||
|
||||
if download && len(attachments) > 0 {
|
||||
for _, a := range attachments {
|
||||
outPath, cached, err := downloadAttachment(cmd, svc, msg.Id, a, attachDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached {
|
||||
u.Out().Printf("Cached: %s", outPath)
|
||||
} else {
|
||||
u.Out().Successf("Saved: %s", outPath)
|
||||
}
|
||||
}
|
||||
u.Out().Println("")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&download, "download", false, "Download attachments")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGmailURLCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "url <threadIds...>",
|
||||
Short: "Print Gmail web URLs for threads",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
u := ui.FromContext(cmd.Context())
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
urls := make([]map[string]string, 0, len(args))
|
||||
for _, id := range args {
|
||||
urls = append(urls, map[string]string{
|
||||
"id": id,
|
||||
"url": fmt.Sprintf("https://mail.google.com/mail/?authuser=%s#all/%s", url.QueryEscape(account), id),
|
||||
})
|
||||
}
|
||||
return outfmt.WriteJSON(os.Stdout, map[string]any{"urls": urls})
|
||||
}
|
||||
for _, id := range args {
|
||||
url := fmt.Sprintf("https://mail.google.com/mail/?authuser=%s#all/%s", url.QueryEscape(account), id)
|
||||
u.Out().Printf("%s\t%s", id, url)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type attachmentInfo struct {
|
||||
Filename string
|
||||
Size int64
|
||||
MimeType string
|
||||
AttachmentID string
|
||||
}
|
||||
|
||||
func collectAttachments(p *gmail.MessagePart) []attachmentInfo {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
var out []attachmentInfo
|
||||
if p.Filename != "" && p.Body != nil && p.Body.AttachmentId != "" {
|
||||
out = append(out, attachmentInfo{
|
||||
Filename: p.Filename,
|
||||
Size: p.Body.Size,
|
||||
MimeType: p.MimeType,
|
||||
AttachmentID: p.Body.AttachmentId,
|
||||
})
|
||||
}
|
||||
for _, part := range p.Parts {
|
||||
out = append(out, collectAttachments(part)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bestBodyText(p *gmail.MessagePart) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
plain := findPartBody(p, "text/plain")
|
||||
if plain != "" {
|
||||
return plain
|
||||
}
|
||||
html := findPartBody(p, "text/html")
|
||||
return html
|
||||
}
|
||||
|
||||
func findPartBody(p *gmail.MessagePart, mimeType string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
if p.MimeType == mimeType && p.Body != nil && p.Body.Data != "" {
|
||||
s, err := decodeBase64URL(p.Body.Data)
|
||||
if err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, part := range p.Parts {
|
||||
if s := findPartBody(part, mimeType); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeBase64URL(s string) (string, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func downloadAttachment(cmd *cobra.Command, svc *gmail.Service, messageID string, a attachmentInfo, dir string) (string, bool, error) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(a.AttachmentID) == "" {
|
||||
return "", false, errors.New("missing messageID/attachmentID")
|
||||
}
|
||||
shortID := a.AttachmentID
|
||||
if len(shortID) > 8 {
|
||||
shortID = shortID[:8]
|
||||
}
|
||||
filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, a.Filename)
|
||||
outPath := filepath.Join(dir, filename)
|
||||
|
||||
if st, err := os.Stat(outPath); err == nil && st.Size() == a.Size && a.Size > 0 {
|
||||
return outPath, true, nil
|
||||
}
|
||||
|
||||
body, err := svc.Users.Messages.Attachments.Get("me", messageID, a.AttachmentID).Context(cmd.Context()).Do()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if body == nil || body.Data == "" {
|
||||
return "", false, errors.New("empty attachment data")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(body.Data)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.WriteFile(outPath, data, 0o600); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return outPath, false, nil
|
||||
}
|
||||
84
internal/cmd/root.go
Normal file
84
internal/cmd/root.go
Normal file
@ -0,0 +1,84 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/gogcli/internal/errfmt"
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
type rootFlags struct {
|
||||
Color string
|
||||
Account string
|
||||
Output string
|
||||
}
|
||||
|
||||
func Execute(args []string) error {
|
||||
flags := rootFlags{Color: envOr("GOG_COLOR", "auto")}
|
||||
flags.Output = envOr("GOG_OUTPUT", "text")
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "gog",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
CompletionOptions: cobra.CompletionOptions{
|
||||
DisableDefaultCmd: true,
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
mode, err := outfmt.Parse(flags.Output)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.SetContext(outfmt.WithMode(cmd.Context(), mode))
|
||||
|
||||
u, err := ui.New(ui.Options{
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
Color: func() string {
|
||||
if outfmt.IsJSON(cmd.Context()) {
|
||||
return "never"
|
||||
}
|
||||
return flags.Color
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.SetContext(ui.WithUI(cmd.Context(), u))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
root.SetArgs(args)
|
||||
root.PersistentFlags().StringVar(&flags.Color, "color", flags.Color, "Color output: auto|always|never")
|
||||
root.PersistentFlags().StringVar(&flags.Account, "account", "", "Account email for API commands")
|
||||
root.PersistentFlags().StringVar(&flags.Output, "output", flags.Output, "Output format: text|json")
|
||||
|
||||
root.AddCommand(newAuthCmd())
|
||||
root.AddCommand(newDriveCmd(&flags))
|
||||
root.AddCommand(newCalendarCmd(&flags))
|
||||
root.AddCommand(newGmailCmd(&flags))
|
||||
root.AddCommand(newContactsCmd(&flags))
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if u := ui.FromContext(root.Context()); u != nil {
|
||||
u.Err().Error(errfmt.Format(err))
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
|
||||
return err
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
100
internal/config/credentials.go
Normal file
100
internal/config/credentials.go
Normal file
@ -0,0 +1,100 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ClientCredentials struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
type googleCredentialsFile struct {
|
||||
Installed *struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
} `json:"installed"`
|
||||
Web *struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
} `json:"web"`
|
||||
}
|
||||
|
||||
func ParseGoogleOAuthClientJSON(b []byte) (ClientCredentials, error) {
|
||||
var f googleCredentialsFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return ClientCredentials{}, err
|
||||
}
|
||||
|
||||
var clientID, clientSecret string
|
||||
if f.Installed != nil {
|
||||
clientID, clientSecret = f.Installed.ClientID, f.Installed.ClientSecret
|
||||
} else if f.Web != nil {
|
||||
clientID, clientSecret = f.Web.ClientID, f.Web.ClientSecret
|
||||
}
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return ClientCredentials{}, errors.New("invalid credentials.json (expected installed/web client_id and client_secret)")
|
||||
}
|
||||
return ClientCredentials{ClientID: clientID, ClientSecret: clientSecret}, nil
|
||||
}
|
||||
|
||||
func WriteClientCredentials(c ClientCredentials) error {
|
||||
_, err := EnsureDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := ClientCredentialsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadClientCredentials() (ClientCredentials, error) {
|
||||
path, err := ClientCredentialsPath()
|
||||
if err != nil {
|
||||
return ClientCredentials{}, err
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ClientCredentials{}, &CredentialsMissingError{Path: path, Cause: err}
|
||||
}
|
||||
return ClientCredentials{}, err
|
||||
}
|
||||
var c ClientCredentials
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return ClientCredentials{}, err
|
||||
}
|
||||
if c.ClientID == "" || c.ClientSecret == "" {
|
||||
return ClientCredentials{}, errors.New("stored credentials.json is missing client_id/client_secret")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type CredentialsMissingError struct {
|
||||
Path string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *CredentialsMissingError) Error() string {
|
||||
return "oauth credentials missing"
|
||||
}
|
||||
|
||||
func (e *CredentialsMissingError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
32
internal/config/credentials_test.go
Normal file
32
internal/config/credentials_test.go
Normal file
@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseGoogleOAuthClientJSON(t *testing.T) {
|
||||
t.Run("installed", func(t *testing.T) {
|
||||
got, err := ParseGoogleOAuthClientJSON([]byte(`{"installed":{"client_id":"id","client_secret":"sec"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.ClientID != "id" || got.ClientSecret != "sec" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web", func(t *testing.T) {
|
||||
got, err := ParseGoogleOAuthClientJSON([]byte(`{"web":{"client_id":"id","client_secret":"sec"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.ClientID != "id" || got.ClientSecret != "sec" {
|
||||
t.Fatalf("unexpected: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
_, err := ParseGoogleOAuthClientJSON([]byte(`{"nope":{}}`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
73
internal/config/paths.go
Normal file
73
internal/config/paths.go
Normal file
@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const AppName = "gogcli"
|
||||
|
||||
func Dir() (string, error) {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(base, AppName), nil
|
||||
}
|
||||
|
||||
func EnsureDir() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func ClientCredentialsPath() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "credentials.json"), nil
|
||||
}
|
||||
|
||||
func DriveDownloadsDir() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "drive-downloads"), nil
|
||||
}
|
||||
|
||||
func EnsureDriveDownloadsDir() (string, error) {
|
||||
dir, err := DriveDownloadsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func GmailAttachmentsDir() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "gmail-attachments"), nil
|
||||
}
|
||||
|
||||
func EnsureGmailAttachmentsDir() (string, error) {
|
||||
dir, err := GmailAttachmentsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
55
internal/errfmt/errfmt.go
Normal file
55
internal/errfmt/errfmt.go
Normal file
@ -0,0 +1,55 @@
|
||||
package errfmt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
ggoogleapi "google.golang.org/api/googleapi"
|
||||
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
gogapi "github.com/steipete/gogcli/internal/googleapi"
|
||||
)
|
||||
|
||||
func Format(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var authErr *gogapi.AuthRequiredError
|
||||
if errors.As(err, &authErr) {
|
||||
return fmt.Sprintf("No refresh token for %s %s. Run: gog auth add %s --services %s", authErr.Service, authErr.Email, authErr.Email, authErr.Service)
|
||||
}
|
||||
|
||||
var scopesErr *gogapi.MissingScopesError
|
||||
if errors.As(err, &scopesErr) {
|
||||
return fmt.Sprintf("Missing scopes for %s %s. Re-run: gog auth add %s --services %s --force-consent", scopesErr.Service, scopesErr.Email, scopesErr.Email, scopesErr.Service)
|
||||
}
|
||||
|
||||
var credErr *config.CredentialsMissingError
|
||||
if errors.As(err, &credErr) {
|
||||
return fmt.Sprintf("OAuth credentials missing. Run: gog auth credentials <credentials.json> (expected at %s)", credErr.Path)
|
||||
}
|
||||
|
||||
if errors.Is(err, keyring.ErrKeyNotFound) {
|
||||
return "Secret not found in keyring (refresh token missing). Run: gog auth add <email>"
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
var gerr *ggoogleapi.Error
|
||||
if errors.As(err, &gerr) {
|
||||
reason := ""
|
||||
if len(gerr.Errors) > 0 && gerr.Errors[0].Reason != "" {
|
||||
reason = gerr.Errors[0].Reason
|
||||
}
|
||||
if reason != "" {
|
||||
return fmt.Sprintf("Google API error (%d %s): %s", gerr.Code, reason, gerr.Message)
|
||||
}
|
||||
return fmt.Sprintf("Google API error (%d): %s", gerr.Code, gerr.Message)
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
17
internal/googleapi/calendar.go
Normal file
17
internal/googleapi/calendar.go
Normal file
@ -0,0 +1,17 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/api/calendar/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewCalendar(ctx context.Context, email string) (*calendar.Service, error) {
|
||||
opts, err := optionsForAccount(ctx, googleauth.ServiceCalendar, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return calendar.NewService(ctx, opts...)
|
||||
}
|
||||
71
internal/googleapi/client.go
Normal file
71
internal/googleapi/client.go
Normal file
@ -0,0 +1,71 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/option"
|
||||
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
"github.com/steipete/gogcli/internal/secrets"
|
||||
)
|
||||
|
||||
func tokenSourceForAccount(ctx context.Context, service googleauth.Service, email string) (oauth2.TokenSource, error) {
|
||||
creds, err := config.ReadClientCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requiredScopes, err := googleauth.Scopes(service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store, err := secrets.OpenDefault()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := store.GetToken(email)
|
||||
if err != nil {
|
||||
if err == keyring.ErrKeyNotFound {
|
||||
return nil, &AuthRequiredError{Service: string(service), Email: email, Cause: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(tok.Scopes) > 0 {
|
||||
have := make(map[string]struct{}, len(tok.Scopes))
|
||||
for _, s := range tok.Scopes {
|
||||
have[s] = struct{}{}
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, want := range requiredScopes {
|
||||
if _, ok := have[want]; !ok {
|
||||
missing = append(missing, want)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, &MissingScopesError{Service: string(service), Email: email, Missing: missing}
|
||||
}
|
||||
}
|
||||
|
||||
cfg := oauth2.Config{
|
||||
ClientID: creds.ClientID,
|
||||
ClientSecret: creds.ClientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
Scopes: requiredScopes,
|
||||
}
|
||||
|
||||
return cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: tok.RefreshToken}), nil
|
||||
}
|
||||
|
||||
func optionsForAccount(ctx context.Context, service googleauth.Service, email string) ([]option.ClientOption, error) {
|
||||
ts, err := tokenSourceForAccount(ctx, service, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []option.ClientOption{option.WithTokenSource(ts)}, nil
|
||||
}
|
||||
17
internal/googleapi/drive.go
Normal file
17
internal/googleapi/drive.go
Normal file
@ -0,0 +1,17 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/api/drive/v3"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewDrive(ctx context.Context, email string) (*drive.Service, error) {
|
||||
opts, err := optionsForAccount(ctx, googleauth.ServiceDrive, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return drive.NewService(ctx, opts...)
|
||||
}
|
||||
27
internal/googleapi/errors.go
Normal file
27
internal/googleapi/errors.go
Normal file
@ -0,0 +1,27 @@
|
||||
package googleapi
|
||||
|
||||
import "fmt"
|
||||
|
||||
type AuthRequiredError struct {
|
||||
Service string
|
||||
Email string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *AuthRequiredError) Error() string {
|
||||
return fmt.Sprintf("auth required for %s %s", e.Service, e.Email)
|
||||
}
|
||||
|
||||
func (e *AuthRequiredError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
type MissingScopesError struct {
|
||||
Service string
|
||||
Email string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
func (e *MissingScopesError) Error() string {
|
||||
return fmt.Sprintf("missing scopes for %s %s", e.Service, e.Email)
|
||||
}
|
||||
17
internal/googleapi/gmail.go
Normal file
17
internal/googleapi/gmail.go
Normal file
@ -0,0 +1,17 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewGmail(ctx context.Context, email string) (*gmail.Service, error) {
|
||||
opts, err := optionsForAccount(ctx, googleauth.ServiceGmail, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gmail.NewService(ctx, opts...)
|
||||
}
|
||||
17
internal/googleapi/people.go
Normal file
17
internal/googleapi/people.go
Normal file
@ -0,0 +1,17 @@
|
||||
package googleapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/api/people/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleauth"
|
||||
)
|
||||
|
||||
func NewPeople(ctx context.Context, email string) (*people.Service, error) {
|
||||
opts, err := optionsForAccount(ctx, googleauth.ServiceContacts, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return people.NewService(ctx, opts...)
|
||||
}
|
||||
225
internal/googleauth/oauth_flow.go
Normal file
225
internal/googleauth/oauth_flow.go
Normal file
@ -0,0 +1,225 @@
|
||||
package googleauth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
)
|
||||
|
||||
type AuthorizeOptions struct {
|
||||
Services []Service
|
||||
Scopes []string
|
||||
Manual bool
|
||||
ForceConsent bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 2 * time.Minute
|
||||
}
|
||||
if len(opts.Scopes) == 0 {
|
||||
return "", errors.New("missing scopes")
|
||||
}
|
||||
creds, err := config.ReadClientCredentials()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
state, err := randomState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
if opts.Manual {
|
||||
redirectURI := "http://localhost:1"
|
||||
cfg := oauth2.Config{
|
||||
ClientID: creds.ClientID,
|
||||
ClientSecret: creds.ClientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: opts.Scopes,
|
||||
}
|
||||
authURL := cfg.AuthCodeURL(state, authURLParams(opts.ForceConsent)...)
|
||||
fmt.Fprintln(os.Stderr, "Visit this URL to authorize:")
|
||||
fmt.Fprintln(os.Stderr, authURL)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "After authorizing, you'll be redirected to a localhost URL that won't load.")
|
||||
fmt.Fprintln(os.Stderr, "Copy the URL from your browser's address bar and paste it here.")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
fmt.Fprint(os.Stderr, "Paste redirect URL: ")
|
||||
line, readErr := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrClosed) {
|
||||
return "", readErr
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
code, gotState, parseErr := extractCodeAndState(line)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
if gotState != "" && gotState != state {
|
||||
return "", errors.New("state mismatch")
|
||||
}
|
||||
tok, exchangeErr := cfg.Exchange(ctx, code)
|
||||
if exchangeErr != nil {
|
||||
return "", exchangeErr
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
return "", errors.New("no refresh token received; try again with --force-consent")
|
||||
}
|
||||
return tok.RefreshToken, nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
|
||||
|
||||
cfg := oauth2.Config{
|
||||
ClientID: creds.ClientID,
|
||||
ClientSecret: creds.ClientSecret,
|
||||
Endpoint: google.Endpoint,
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: opts.Scopes,
|
||||
}
|
||||
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth2/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("error") != "" {
|
||||
select {
|
||||
case errCh <- fmt.Errorf("authorization error: %s", q.Get("error")):
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("Authorization cancelled. You can close this window."))
|
||||
return
|
||||
}
|
||||
if q.Get("state") != state {
|
||||
select {
|
||||
case errCh <- errors.New("state mismatch"):
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("State mismatch. You can close this window."))
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
select {
|
||||
case errCh <- errors.New("missing code"):
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("Missing code. You can close this window."))
|
||||
return
|
||||
}
|
||||
select {
|
||||
case codeCh <- code:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("Success! You can close this window."))
|
||||
}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = srv.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
authURL := cfg.AuthCodeURL(state, authURLParams(opts.ForceConsent)...)
|
||||
fmt.Fprintln(os.Stderr, "Opening browser for authorization…")
|
||||
fmt.Fprintln(os.Stderr, "If the browser doesn't open, visit this URL:")
|
||||
fmt.Fprintln(os.Stderr, authURL)
|
||||
_ = openBrowser(authURL)
|
||||
|
||||
select {
|
||||
case code := <-codeCh:
|
||||
_ = srv.Close()
|
||||
tok, exchangeErr := cfg.Exchange(ctx, code)
|
||||
if exchangeErr != nil {
|
||||
return "", exchangeErr
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
return "", errors.New("no refresh token received; try again with --force-consent")
|
||||
}
|
||||
return tok.RefreshToken, nil
|
||||
case err := <-errCh:
|
||||
_ = srv.Close()
|
||||
return "", err
|
||||
case <-ctx.Done():
|
||||
_ = srv.Close()
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func authURLParams(forceConsent bool) []oauth2.AuthCodeOption {
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.SetAuthURLParam("include_granted_scopes", "true"),
|
||||
}
|
||||
if forceConsent {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func randomState() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func extractCodeAndState(rawURL string) (code string, state string, err error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
q := parsed.Query()
|
||||
code = q.Get("code")
|
||||
if code == "" {
|
||||
return "", "", errors.New("no code found in URL")
|
||||
}
|
||||
return code, q.Get("state"), nil
|
||||
}
|
||||
19
internal/googleauth/open_browser.go
Normal file
19
internal/googleauth/open_browser.go
Normal file
@ -0,0 +1,19 @@
|
||||
package googleauth
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openBrowser(u string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", u)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", u)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
68
internal/googleauth/service.go
Normal file
68
internal/googleauth/service.go
Normal file
@ -0,0 +1,68 @@
|
||||
package googleauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Service string
|
||||
|
||||
const (
|
||||
ServiceGmail Service = "gmail"
|
||||
ServiceCalendar Service = "calendar"
|
||||
ServiceDrive Service = "drive"
|
||||
ServiceContacts Service = "contacts"
|
||||
)
|
||||
|
||||
func ParseService(s string) (Service, error) {
|
||||
switch Service(strings.ToLower(strings.TrimSpace(s))) {
|
||||
case ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts:
|
||||
return Service(strings.ToLower(strings.TrimSpace(s))), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown service %q (expected gmail|calendar|drive|contacts)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func AllServices() []Service {
|
||||
return []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts}
|
||||
}
|
||||
|
||||
func Scopes(service Service) ([]string, error) {
|
||||
switch service {
|
||||
case ServiceGmail:
|
||||
return []string{"https://mail.google.com/"}, nil
|
||||
case ServiceCalendar:
|
||||
return []string{"https://www.googleapis.com/auth/calendar"}, nil
|
||||
case ServiceDrive:
|
||||
return []string{"https://www.googleapis.com/auth/drive"}, nil
|
||||
case ServiceContacts:
|
||||
return []string{
|
||||
"https://www.googleapis.com/auth/contacts",
|
||||
"https://www.googleapis.com/auth/directory.readonly",
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.New("unknown service")
|
||||
}
|
||||
}
|
||||
|
||||
func ScopesForServices(services []Service) ([]string, error) {
|
||||
set := make(map[string]struct{})
|
||||
for _, svc := range services {
|
||||
scopes, err := Scopes(svc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range scopes {
|
||||
set[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
out = append(out, s)
|
||||
}
|
||||
// stable ordering (useful for tests + auth URL diffs)
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
35
internal/googleauth/service_test.go
Normal file
35
internal/googleauth/service_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package googleauth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseService(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want Service
|
||||
}{
|
||||
{"gmail", ServiceGmail},
|
||||
{"GMAIL", ServiceGmail},
|
||||
{"calendar", ServiceCalendar},
|
||||
{"drive", ServiceDrive},
|
||||
{"contacts", ServiceContacts},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseService(tt.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseService(%q) err: %v", tt.in, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("ParseService(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCodeAndState(t *testing.T) {
|
||||
code, state, err := extractCodeAndState("http://localhost:1/?code=abc&state=xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if code != "abc" || state != "xyz" {
|
||||
t.Fatalf("unexpected: code=%q state=%q", code, state)
|
||||
}
|
||||
}
|
||||
94
internal/integration/integration_test.go
Normal file
94
internal/integration/integration_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/gogcli/internal/googleapi"
|
||||
)
|
||||
|
||||
func TestDriveSmoke(t *testing.T) {
|
||||
account := os.Getenv("GOG_IT_ACCOUNT")
|
||||
if account == "" {
|
||||
t.Skip("set GOG_IT_ACCOUNT")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
svc, err := googleapi.NewDrive(ctx, account)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDrive: %v", err)
|
||||
}
|
||||
_, err = svc.Files.List().
|
||||
Q("trashed = false").
|
||||
PageSize(1).
|
||||
SupportsAllDrives(true).
|
||||
IncludeItemsFromAllDrives(true).
|
||||
Fields("files(id)").
|
||||
Do()
|
||||
if err != nil {
|
||||
t.Fatalf("Drive list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarSmoke(t *testing.T) {
|
||||
account := os.Getenv("GOG_IT_ACCOUNT")
|
||||
if account == "" {
|
||||
t.Skip("set GOG_IT_ACCOUNT")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
svc, err := googleapi.NewCalendar(ctx, account)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCalendar: %v", err)
|
||||
}
|
||||
_, err = svc.CalendarList.List().MaxResults(1).Do()
|
||||
if err != nil {
|
||||
t.Fatalf("Calendar list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGmailSmoke(t *testing.T) {
|
||||
account := os.Getenv("GOG_IT_ACCOUNT")
|
||||
if account == "" {
|
||||
t.Skip("set GOG_IT_ACCOUNT")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
svc, err := googleapi.NewGmail(ctx, account)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGmail: %v", err)
|
||||
}
|
||||
_, err = svc.Users.Labels.List("me").Do()
|
||||
if err != nil {
|
||||
t.Fatalf("Gmail labels: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsSmoke(t *testing.T) {
|
||||
account := os.Getenv("GOG_IT_ACCOUNT")
|
||||
if account == "" {
|
||||
t.Skip("set GOG_IT_ACCOUNT")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
svc, err := googleapi.NewPeople(ctx, account)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeople: %v", err)
|
||||
}
|
||||
_, err = svc.People.Connections.List("people/me").PersonFields("names").PageSize(1).Do()
|
||||
if err != nil {
|
||||
t.Fatalf("People connections: %v", err)
|
||||
}
|
||||
}
|
||||
53
internal/outfmt/outfmt.go
Normal file
53
internal/outfmt/outfmt.go
Normal file
@ -0,0 +1,53 @@
|
||||
package outfmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeText Mode = "text"
|
||||
ModeJSON Mode = "json"
|
||||
)
|
||||
|
||||
func Parse(s string) (Mode, error) {
|
||||
switch Mode(strings.ToLower(strings.TrimSpace(s))) {
|
||||
case ModeText, "":
|
||||
return ModeText, nil
|
||||
case ModeJSON:
|
||||
return ModeJSON, nil
|
||||
default:
|
||||
return "", errors.New("invalid --output (expected text|json)")
|
||||
}
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func WithMode(ctx context.Context, mode Mode) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, mode)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) Mode {
|
||||
if v := ctx.Value(ctxKey{}); v != nil {
|
||||
if m, ok := v.(Mode); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return ModeText
|
||||
}
|
||||
|
||||
func IsJSON(ctx context.Context) bool {
|
||||
return FromContext(ctx) == ModeJSON
|
||||
}
|
||||
|
||||
func WriteJSON(w io.Writer, v any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
150
internal/secrets/store.go
Normal file
150
internal/secrets/store.go
Normal file
@ -0,0 +1,150 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"github.com/steipete/gogcli/internal/config"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Keys() ([]string, error)
|
||||
SetToken(email string, tok Token) error
|
||||
GetToken(email string) (Token, error)
|
||||
DeleteToken(email string) error
|
||||
ListTokens() ([]Token, error)
|
||||
}
|
||||
|
||||
type KeyringStore struct {
|
||||
ring keyring.Keyring
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Email string `json:"email"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
RefreshToken string `json:"-"`
|
||||
}
|
||||
|
||||
func OpenDefault() (Store, error) {
|
||||
ring, err := keyring.Open(keyring.Config{
|
||||
ServiceName: config.AppName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &KeyringStore{ring: ring}, nil
|
||||
}
|
||||
|
||||
func (s *KeyringStore) Keys() ([]string, error) {
|
||||
return s.ring.Keys()
|
||||
}
|
||||
|
||||
type storedToken struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *KeyringStore) SetToken(email string, tok Token) error {
|
||||
email = normalize(email)
|
||||
if email == "" {
|
||||
return fmt.Errorf("missing email")
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
return fmt.Errorf("missing refresh token")
|
||||
}
|
||||
if tok.CreatedAt.IsZero() {
|
||||
tok.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(storedToken{
|
||||
RefreshToken: tok.RefreshToken,
|
||||
Services: tok.Services,
|
||||
Scopes: tok.Scopes,
|
||||
CreatedAt: tok.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.ring.Set(keyring.Item{
|
||||
Key: tokenKey(email),
|
||||
Data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *KeyringStore) GetToken(email string) (Token, error) {
|
||||
email = normalize(email)
|
||||
if email == "" {
|
||||
return Token{}, fmt.Errorf("missing email")
|
||||
}
|
||||
it, err := s.ring.Get(tokenKey(email))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
var st storedToken
|
||||
if err := json.Unmarshal(it.Data, &st); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
return Token{
|
||||
Email: email,
|
||||
Services: st.Services,
|
||||
Scopes: st.Scopes,
|
||||
CreatedAt: st.CreatedAt,
|
||||
RefreshToken: st.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *KeyringStore) DeleteToken(email string) error {
|
||||
email = normalize(email)
|
||||
if email == "" {
|
||||
return fmt.Errorf("missing email")
|
||||
}
|
||||
return s.ring.Remove(tokenKey(email))
|
||||
}
|
||||
|
||||
func (s *KeyringStore) ListTokens() ([]Token, error) {
|
||||
keys, err := s.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Token, 0)
|
||||
for _, k := range keys {
|
||||
email, ok := ParseTokenKey(k)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tok, err := s.GetToken(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tok)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ParseTokenKey(k string) (email string, ok bool) {
|
||||
const prefix = "token:"
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(k, prefix)
|
||||
if strings.TrimSpace(rest) == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func tokenKey(email string) string {
|
||||
return fmt.Sprintf("token:%s", email)
|
||||
}
|
||||
|
||||
func normalize(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
22
internal/secrets/store_test.go
Normal file
22
internal/secrets/store_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package secrets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTokenKey(t *testing.T) {
|
||||
if got := tokenKey("a@b.com"); got != "token:a@b.com" {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenKey(t *testing.T) {
|
||||
email, ok := ParseTokenKey("token:a@b.com")
|
||||
if !ok {
|
||||
t.Fatalf("expected ok")
|
||||
}
|
||||
if email != "a@b.com" {
|
||||
t.Fatalf("unexpected: %q", email)
|
||||
}
|
||||
if _, ok := ParseTokenKey("nope"); ok {
|
||||
t.Fatalf("expected not ok")
|
||||
}
|
||||
}
|
||||
121
internal/ui/ui.go
Normal file
121
internal/ui/ui.go
Normal file
@ -0,0 +1,121 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Color string // auto|always|never
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
out *Printer
|
||||
err *Printer
|
||||
}
|
||||
|
||||
func New(opts Options) (*UI, error) {
|
||||
if opts.Stdout == nil {
|
||||
opts.Stdout = os.Stdout
|
||||
}
|
||||
if opts.Stderr == nil {
|
||||
opts.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
colorMode := strings.ToLower(strings.TrimSpace(opts.Color))
|
||||
if colorMode == "" {
|
||||
colorMode = "auto"
|
||||
}
|
||||
if colorMode != "auto" && colorMode != "always" && colorMode != "never" {
|
||||
return nil, errors.New("invalid --color (expected auto|always|never)")
|
||||
}
|
||||
|
||||
out := termenv.NewOutput(opts.Stdout, termenv.WithProfile(termenv.EnvColorProfile()))
|
||||
errOut := termenv.NewOutput(opts.Stderr, termenv.WithProfile(termenv.EnvColorProfile()))
|
||||
|
||||
outProfile := chooseProfile(out.Profile, colorMode)
|
||||
errProfile := chooseProfile(errOut.Profile, colorMode)
|
||||
|
||||
return &UI{
|
||||
out: newPrinter(out, outProfile),
|
||||
err: newPrinter(errOut, errProfile),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chooseProfile(detected termenv.Profile, mode string) termenv.Profile {
|
||||
if termenv.EnvNoColor() {
|
||||
return termenv.Ascii
|
||||
}
|
||||
switch mode {
|
||||
case "never":
|
||||
return termenv.Ascii
|
||||
case "always":
|
||||
return termenv.TrueColor
|
||||
default:
|
||||
return detected
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UI) Out() *Printer { return u.out }
|
||||
func (u *UI) Err() *Printer { return u.err }
|
||||
|
||||
type Printer struct {
|
||||
o *termenv.Output
|
||||
profile termenv.Profile
|
||||
}
|
||||
|
||||
func newPrinter(o *termenv.Output, profile termenv.Profile) *Printer {
|
||||
return &Printer{o: o, profile: profile}
|
||||
}
|
||||
|
||||
func (p *Printer) ColorEnabled() bool { return p.profile != termenv.Ascii }
|
||||
|
||||
func (p *Printer) line(s string) {
|
||||
_, _ = io.WriteString(p.o, s+"\n")
|
||||
}
|
||||
|
||||
func (p *Printer) printf(format string, args ...any) {
|
||||
p.line(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (p *Printer) Successf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if p.ColorEnabled() {
|
||||
msg = termenv.String(msg).Foreground(p.profile.Color("#22c55e")).String()
|
||||
}
|
||||
p.line(msg)
|
||||
}
|
||||
|
||||
func (p *Printer) Error(msg string) {
|
||||
if p.ColorEnabled() {
|
||||
msg = termenv.String(msg).Foreground(p.profile.Color("#ef4444")).String()
|
||||
}
|
||||
p.line(msg)
|
||||
}
|
||||
|
||||
func (p *Printer) Errorf(format string, args ...any) { p.Error(fmt.Sprintf(format, args...)) }
|
||||
func (p *Printer) Printf(format string, args ...any) { p.printf(format, args...) }
|
||||
func (p *Printer) Println(msg string) { p.line(msg) }
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func WithUI(ctx context.Context, u *UI) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, u)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *UI {
|
||||
v := ctx.Value(ctxKey{})
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
u, _ := v.(*UI)
|
||||
return u
|
||||
}
|
||||
13
internal/ui/ui_test.go
Normal file
13
internal/ui/ui_test.go
Normal file
@ -0,0 +1,13 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUIColorFlagValidation(t *testing.T) {
|
||||
_, err := New(Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Color: "nope"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user