diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 067498c..c6d179a 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -69,8 +69,12 @@ jobs: for arch in amd64 arm64; do artifact="dist/${BINARY_NAME}-${os}-${arch}.tar.gz" tmpdir=$(mktemp -d) + # Build main binary CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w -X roam-cli/internal/cli.Version=${RELEASE_TAG#${TAG_PREFIX}}" -o "$tmpdir/${BINARY_NAME}" "$BUILD_TARGET" - tar -czf "$artifact" -C "$tmpdir" "$BINARY_NAME" + # Build 1Password shell plugin binary + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags="-s -w" -o "$tmpdir/roamresearch" ./contrib/1password-plugin/ + # Archive both binaries + tar -czf "$artifact" -C "$tmpdir" "${BINARY_NAME}" "roamresearch" rm -rf "$tmpdir" done done diff --git a/.gitignore b/.gitignore index 22274f2..501ce66 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ +# Local env +.env + # Binaries /bin/ *.exe *.test *.out +contrib/1password-plugin/1password-plugin # Build artifacts dist/ diff --git a/Makefile b/Makefile index 19d89b0..df0388c 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,14 @@ OUT_DIR := dist CMD := ./cmd/roam-cli GOFLAGS ?= -buildvcs=false +OP_PLUGIN_NAME := roamresearch +OP_PLUGIN_DIR := contrib/1password-plugin +OP_PLUGIN_BIN := $(BIN_DIR)/$(OP_PLUGIN_NAME) + export GOFLAGS .PHONY: tidy fmt test bdd-test ci build run install clean cross-build help +.PHONY: op-plugin-test op-plugin-build op-plugin-install-local help: @echo "Targets:" @@ -21,6 +26,10 @@ help: @echo " make install - install binary to GOPATH/bin" @echo " make clean - remove build artifacts" @echo " make cross-build - build darwin/linux amd64/arm64 binaries" + @echo " make op-plugin-test - run 1Password plugin tests" + @echo " make op-plugin-build - build 1Password plugin binary" + @echo " make op-plugin-install-local - build and install plugin locally" + tidy: go mod tidy @@ -40,6 +49,7 @@ ci: go vet ./... go test ./... -count=1 go test -tags=bdd ./tests/bdd/... -count=1 + cd $(OP_PLUGIN_DIR) && go test ./... -count=1 mkdir -p $(BIN_DIR) go build -v -o $(BIN_PATH) $(CMD) @@ -56,6 +66,19 @@ install: clean: rm -rf $(OUT_DIR) $(BIN_DIR) +op-plugin-test: + cd $(OP_PLUGIN_DIR) && go test ./... -count=1 + +op-plugin-build: + mkdir -p $(BIN_DIR) + cd $(OP_PLUGIN_DIR) && go build -o ../../$(OP_PLUGIN_BIN) . + +op-plugin-install-local: op-plugin-build + mkdir -p ~/.op/plugins/local + chmod 700 ~/.op ~/.op/plugins ~/.op/plugins/local + cp $(OP_PLUGIN_BIN) ~/.op/plugins/local/$(OP_PLUGIN_NAME) + chmod 755 ~/.op/plugins/local/$(OP_PLUGIN_NAME) + cross-build: clean mkdir -p $(OUT_DIR) GOOS=darwin GOARCH=amd64 go build -o $(OUT_DIR)/$(BINARY_NAME)-darwin-amd64 $(CMD) diff --git a/README.md b/README.md index e9df62f..962eef1 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,62 @@ Use 1Password CLI to inject credentials at runtime: - https://developer.1password.com/docs/service-accounts/use-with-1password-cli -Example: +### Path A: Simple op run (existing) + +Inject credentials from environment variables on each invocation: + +```bash +export ROAM_API_TOKEN="op://Private/Roam Research/token" +export ROAM_API_GRAPH="op://Private/Roam Research/graph" + +op run -- roam-cli status +op run -- roam-cli get "Page Title" +``` + +### Path B: 1Password Shell Plugin (recommended) + +The shell plugin lets 1Password CLI automatically inject credentials when you run `roam-cli`, without managing `.env` files. + +Prerequisites: +- [1Password CLI](https://1password.com/downloads/command-line) installed +- A 1Password item with fields matching the environment variables below + +Install the local plugin: + +```bash +# Install the 1Password shell plugin binary. +roam-cli onepassword install + +# Let 1Password create the shell wrapper. +op plugin init roam-cli + +# Reload shell plugin aliases. +source ~/.config/op/plugins.sh + +# Run normally. +roam-cli status +``` + +> `op plugin init roam-cli` only works after `roam-cli onepassword install` has copied the local plugin binary to `~/.op/plugins/local/roamresearch`. + +#### Release archives + +Starting from the release that includes this feature, each release archive contains both `roam-cli` and `roamresearch` binaries. Extract both to your `PATH` before running `roam-cli onepassword install`. + +#### Developer flow + +```bash +make build op-plugin-build +./bin/roam-cli onepassword install --from ./bin/roamresearch --force +``` + +#### Manual shell wrapper + +If you prefer not to use `op plugin init`, add this to your shell rc file: ```bash -op run --env-file=.env -- roam-cli status -op run --env-file=.env -- roam-cli get "Page Title" +roam-cli() { + op plugin run -- roam-cli "$@" +} +export OP_PLUGIN_ALIASES_SOURCED=1 ``` diff --git a/contrib/1password-plugin/credential.go b/contrib/1password-plugin/credential.go new file mode 100644 index 0000000..260fff0 --- /dev/null +++ b/contrib/1password-plugin/credential.go @@ -0,0 +1,61 @@ +package main + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/importer" + "github.com/1Password/shell-plugins/sdk/provision" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +const ( + fieldToken = sdk.FieldName("Token") + fieldGraph = sdk.FieldName("Graph") + fieldAPIURL = sdk.FieldName("API URL") + fieldTimeoutSeconds = sdk.FieldName("Timeout Seconds") +) + +var envVarMapping = map[string]sdk.FieldName{ + "ROAM_API_TOKEN": fieldToken, + "ROAM_API_GRAPH": fieldGraph, + "ROAM_API_BASE_URL": fieldAPIURL, + "ROAM_TIMEOUT_SECONDS": fieldTimeoutSeconds, +} + +// APIToken returns the credential type for Roam Research API Token. +func APIToken() schema.CredentialType { + return schema.CredentialType{ + Name: credname.APIToken, + DocsURL: sdk.URL("https://github.com/Leechael/roam-cli"), + ManagementURL: sdk.URL("https://roamresearch.com/#/app/roam-cli-settings"), + + Fields: []schema.CredentialField{ + { + Name: fieldToken, + MarkdownDescription: "Roam Research API token used to authenticate requests.", + Secret: true, + }, + { + Name: fieldGraph, + MarkdownDescription: "Roam Research graph name.", + Secret: false, + }, + { + Name: fieldAPIURL, + MarkdownDescription: "Optional custom API base URL.", + Secret: false, + Optional: true, + }, + { + Name: fieldTimeoutSeconds, + MarkdownDescription: "Optional request timeout in seconds.", + Secret: false, + Optional: true, + }, + }, + + DefaultProvisioner: provision.EnvVars(envVarMapping), + + Importer: importer.TryEnvVarPair(envVarMapping), + } +} diff --git a/contrib/1password-plugin/credential_test.go b/contrib/1password-plugin/credential_test.go new file mode 100644 index 0000000..9f4fbbb --- /dev/null +++ b/contrib/1password-plugin/credential_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "testing" + + "github.com/1Password/shell-plugins/sdk" +) + +func TestCredentialTypeName(t *testing.T) { + ct := APIToken() + if string(ct.Name) != "API Token" { + t.Errorf("expected %q, got %q", "API Token", ct.Name) + } +} + +func TestCredentialFields(t *testing.T) { + ct := APIToken() + + if len(ct.Fields) != 4 { + t.Fatalf("expected 4 fields, got %d", len(ct.Fields)) + } + + tests := []struct { + name sdk.FieldName + secret bool + optional bool + }{ + {fieldToken, true, false}, + {fieldGraph, false, false}, + {fieldAPIURL, false, true}, + {fieldTimeoutSeconds, false, true}, + } + + for _, tt := range tests { + found := false + for _, f := range ct.Fields { + if f.Name == tt.name { + found = true + if f.Secret != tt.secret { + t.Errorf("field %q: secret = %v, want %v", tt.name, f.Secret, tt.secret) + } + if f.Optional != tt.optional { + t.Errorf("field %q: optional = %v, want %v", tt.name, f.Optional, tt.optional) + } + break + } + } + if !found { + t.Errorf("field %q not found", tt.name) + } + } +} + +func TestDefaultProvisionerProducesEnvVars(t *testing.T) { + ct := APIToken() + + if ct.DefaultProvisioner == nil { + t.Fatal("DefaultProvisioner is nil") + } + + in := sdk.ProvisionInput{ + ItemFields: map[sdk.FieldName]string{ + fieldToken: "tok_abc", + fieldGraph: "my-graph", + fieldAPIURL: "https://custom.api/graph", + fieldTimeoutSeconds: "15", + }, + } + + out := sdk.ProvisionOutput{Environment: make(map[string]string)} + ct.DefaultProvisioner.Provision(context.Background(), in, &out) + + env := out.Environment + if env["ROAM_API_TOKEN"] != "tok_abc" { + t.Errorf("ROAM_API_TOKEN = %q, want %q", env["ROAM_API_TOKEN"], "tok_abc") + } + if env["ROAM_API_GRAPH"] != "my-graph" { + t.Errorf("ROAM_API_GRAPH = %q, want %q", env["ROAM_API_GRAPH"], "my-graph") + } + if env["ROAM_API_BASE_URL"] != "https://custom.api/graph" { + t.Errorf("ROAM_API_BASE_URL = %q, want %q", env["ROAM_API_BASE_URL"], "https://custom.api/graph") + } + if env["ROAM_TIMEOUT_SECONDS"] != "15" { + t.Errorf("ROAM_TIMEOUT_SECONDS = %q, want %q", env["ROAM_TIMEOUT_SECONDS"], "15") + } +} + +func TestDefaultProvisionerIsEnvVars(t *testing.T) { + ct := APIToken() + if ct.DefaultProvisioner == nil { + t.Fatal("DefaultProvisioner is nil") + } + desc := ct.DefaultProvisioner.Description() + if desc == "" { + t.Error("DefaultProvisioner description is empty") + } +} + +func TestImporterImportsEnvVars(t *testing.T) { + ct := APIToken() + + t.Setenv("ROAM_API_TOKEN", "tok_imported") + t.Setenv("ROAM_API_GRAPH", "imported-graph") + t.Setenv("ROAM_API_BASE_URL", "https://imported.api/graph") + t.Setenv("ROAM_TIMEOUT_SECONDS", "42") + + out := sdk.ImportOutput{} + ct.Importer(context.Background(), sdk.ImportInput{}, &out) + + candidates := out.AllCandidates() + if len(candidates) == 0 { + t.Fatal("expected at least 1 import candidate") + } + + cand := candidates[0] + if cand.Fields[fieldToken] != "tok_imported" { + t.Errorf("Token = %q, want %q", cand.Fields[fieldToken], "tok_imported") + } + if cand.Fields[fieldGraph] != "imported-graph" { + t.Errorf("Graph = %q, want %q", cand.Fields[fieldGraph], "imported-graph") + } + if cand.Fields[fieldAPIURL] != "https://imported.api/graph" { + t.Errorf("API URL = %q, want %q", cand.Fields[fieldAPIURL], "https://imported.api/graph") + } + if cand.Fields[fieldTimeoutSeconds] != "42" { + t.Errorf("Timeout Seconds = %q, want %q", cand.Fields[fieldTimeoutSeconds], "42") + } +} diff --git a/contrib/1password-plugin/executable.go b/contrib/1password-plugin/executable.go new file mode 100644 index 0000000..b8a941f --- /dev/null +++ b/contrib/1password-plugin/executable.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/needsauth" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/1Password/shell-plugins/sdk/schema/credname" +) + +// RoamCLI returns the executable schema for the roam-cli binary. +func RoamCLI() schema.Executable { + return schema.Executable{ + Name: "Roam Research CLI", + Runs: []string{"roam-cli"}, + DocsURL: sdk.URL("https://github.com/Leechael/roam-cli"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + needsauth.NotWhenContainsArgs("completion"), + needsauth.NotWhenContainsArgs("__complete"), + needsauth.NotWhenContainsArgs("__completeNoDesc"), + ), + Uses: []schema.CredentialUsage{ + {Name: credname.APIToken}, + }, + } +} diff --git a/contrib/1password-plugin/go.mod b/contrib/1password-plugin/go.mod new file mode 100644 index 0000000..1e82bba --- /dev/null +++ b/contrib/1password-plugin/go.mod @@ -0,0 +1,27 @@ +module github.com/Leechael/roam-cli/contrib/1password-plugin + +go 1.22 + +require ( + github.com/1Password/shell-plugins v0.0.0-20260604224627-af9327a7375e + github.com/hashicorp/go-plugin v1.6.3 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/contrib/1password-plugin/go.sum b/contrib/1password-plugin/go.sum new file mode 100644 index 0000000..178ab2b --- /dev/null +++ b/contrib/1password-plugin/go.sum @@ -0,0 +1,64 @@ +github.com/1Password/shell-plugins v0.0.0-20260604224627-af9327a7375e h1:zNqMNVvNbBu3rUG6n8vALLEj5xGFoY2LD/w8rNmzZtQ= +github.com/1Password/shell-plugins v0.0.0-20260604224627-af9327a7375e/go.mod h1:EvnCmOiwWP0oOslzbvy+lFDHs8dT2LOVdH2ID98Y170= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/contrib/1password-plugin/main.go b/contrib/1password-plugin/main.go new file mode 100644 index 0000000..93cfe3a --- /dev/null +++ b/contrib/1password-plugin/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "github.com/1Password/shell-plugins/sdk/rpc/proto" + "github.com/1Password/shell-plugins/sdk/rpc/server" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/hashicorp/go-plugin" +) + +func main() { + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: plugin.HandshakeConfig{ + ProtocolVersion: proto.Version, + MagicCookieKey: proto.MagicCookieKey, + MagicCookieValue: proto.MagicCookieValue, + }, + Plugins: plugin.PluginSet{ + "plugin": &server.RPCPlugin{RPCPlugin: func() (schema.Plugin, error) { + return New(), nil + }}, + }, + }) +} diff --git a/contrib/1password-plugin/plugin.go b/contrib/1password-plugin/plugin.go new file mode 100644 index 0000000..80e7e61 --- /dev/null +++ b/contrib/1password-plugin/plugin.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/1Password/shell-plugins/sdk" + "github.com/1Password/shell-plugins/sdk/schema" +) + +// New returns the Roam Research 1Password shell plugin definition. +func New() schema.Plugin { + return schema.Plugin{ + Name: "roamresearch", + Platform: schema.PlatformInfo{ + Name: "Roam Research", + Homepage: sdk.URL("https://roamresearch.com"), + }, + Credentials: []schema.CredentialType{APIToken()}, + Executables: []schema.Executable{RoamCLI()}, + } +} diff --git a/contrib/1password-plugin/plugin_test.go b/contrib/1password-plugin/plugin_test.go new file mode 100644 index 0000000..7fee59c --- /dev/null +++ b/contrib/1password-plugin/plugin_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "testing" + + "github.com/1Password/shell-plugins/sdk" +) + +func TestPluginName(t *testing.T) { + p := New() + if p.Name != "roamresearch" { + t.Errorf("expected plugin name %q, got %q", "roamresearch", p.Name) + } +} + +func TestPluginHasCredentials(t *testing.T) { + p := New() + if len(p.Credentials) == 0 { + t.Fatal("plugin has no credential types") + } +} + +func TestPluginHasExecutables(t *testing.T) { + p := New() + if len(p.Executables) == 0 { + t.Fatal("plugin has no executables") + } +} + +func TestNeedsAuthReturnsFalseForNoArgs(t *testing.T) { + exec := RoamCLI() + if exec.NeedsAuth == nil { + t.Fatal("NeedsAuth is nil") + } + + input := sdk.NeedsAuthenticationInput{ + CommandArgs: []string{}, + } + if exec.NeedsAuth(input) { + t.Error("expected NeedsAuth to be false for no args") + } +} + +func TestNeedsAuthReturnsFalseForHelp(t *testing.T) { + exec := RoamCLI() + + tests := []struct { + name string + args []string + }{ + {"--help", []string{"--help"}}, + {"help subcommand", []string{"help"}}, + {"--version", []string{"--version"}}, + {"completion command", []string{"completion", "zsh"}}, + {"cobra shell completion", []string{"__complete", "get", ""}}, + {"cobra shell completion no descriptions", []string{"__completeNoDesc", "get", ""}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := sdk.NeedsAuthenticationInput{ + CommandArgs: tt.args, + } + if exec.NeedsAuth(input) { + t.Errorf("expected NeedsAuth to be false for args %v", tt.args) + } + }) + } +} + +func TestNeedsAuthReturnsTrueForStatus(t *testing.T) { + exec := RoamCLI() + + input := sdk.NeedsAuthenticationInput{ + CommandArgs: []string{"status"}, + } + if !exec.NeedsAuth(input) { + t.Error("expected NeedsAuth to be true for 'status'") + } +} + +func TestNeedsAuthReturnsTrueForGetWithArgs(t *testing.T) { + exec := RoamCLI() + + input := sdk.NeedsAuthenticationInput{ + CommandArgs: []string{"get", "--today"}, + } + if !exec.NeedsAuth(input) { + t.Error("expected NeedsAuth to be true for 'get --today'") + } +} + +func TestSchemaDeepValidationHasNoErrors(t *testing.T) { + for _, report := range New().DeepValidate() { + if !report.HasErrors() { + continue + } + for _, check := range report.Checks { + if !check.Assertion { + t.Errorf("%s: %s", report.Heading, check.Description) + } + } + } +} diff --git a/docs/help/topics/configuration.md b/docs/help/topics/configuration.md new file mode 100644 index 0000000..abbdacd --- /dev/null +++ b/docs/help/topics/configuration.md @@ -0,0 +1,37 @@ +# Configuration + +## Environment Variables + +`roam-cli` reads credentials from environment variables: + +| Variable | Required | Description | +|---|---|---| +| `ROAM_API_TOKEN` | Yes | Roam Research API token | +| `ROAM_API_GRAPH` | Yes | Roam Research graph name | +| `ROAM_API_BASE_URL` | No | Custom API base URL (default: `https://api.roamresearch.com/api/graph`) | +| `ROAM_TIMEOUT_SECONDS` | No | Request timeout in seconds (default: 30) | + +## Secret Management + +### Path A: op run + +Inject credentials from a `.env` file using 1Password CLI: + +```bash +op run --env-file=.env -- roam-cli status +``` + +### Path B: 1Password Shell Plugin + +Prerequisites: [1Password CLI](https://1password.com/downloads/command-line) installed and a 1Password item storing your Roam Research credentials. + +Install the local plugin: + +```bash +roam-cli onepassword install +op plugin init roam-cli +source ~/.config/op/plugins.sh +roam-cli status +``` + +> `op plugin init roam-cli` only works after `roam-cli onepassword install` has copied the local plugin binary to `~/.op/plugins/local/roamresearch`. diff --git a/docs/plans/1password-shell-plugin.md b/docs/plans/1password-shell-plugin.md new file mode 100644 index 0000000..d6711ca --- /dev/null +++ b/docs/plans/1password-shell-plugin.md @@ -0,0 +1,500 @@ +# 1Password Shell Plugin Support Plan + +## Purpose + +Add local 1Password Shell Plugin support for `roam-cli` inside this repository. Do not submit this as an official plugin to `1Password/shell-plugins`. + +The target user experience is: + +```bash +roam-cli onepassword install +op plugin init roam-cli +source ~/.config/op/plugins.sh +roam-cli status +``` + +## Fixed Decisions + +These decisions are part of the implementation contract. Do not rename or substitute them during implementation. + +| Topic | Decision | +|---|---| +| Main CLI command | `roam-cli onepassword install` | +| Plugin name / local plugin binary | `roamresearch` | +| Plugin executable usage | `roam-cli` | +| Local install destination | `~/.op/plugins/local/roamresearch` | +| Runtime install strategy | Copy a trusted `roamresearch` binary from the release bundle, same directory as `roam-cli`, `PATH`, or explicit `--from` | +| Network download in installer | No | +| Official 1Password plugin submission | No | +| Current `op run --env-file` support | Keep | + +Use `onepassword`, not `1password`, as the CLI command name. Cobra commands that start with a digit are awkward for help, tests, and shell usage. + +## Current State + +`roam-cli` already reads credentials from environment variables in `internal/config/env.go`: + +- `ROAM_API_TOKEN` +- `ROAM_API_GRAPH` +- optional: `ROAM_API_BASE_URL` +- optional: `ROAM_TIMEOUT_SECONDS` + +No auth logic change is required in the main client. The shell plugin only injects these environment variables before executing the real `roam-cli` binary. + +## 1Password Discovery Model + +`op plugin init roam-cli` does not scan this repository or the current working directory. + +1Password CLI discovers shell plugins from: + +1. plugins bundled with the installed `op` binary +2. local plugin binaries under `~/.op/plugins/local/` + +For local plugins, `op` starts the plugin binary and reads its schema over RPC. A source-only plugin in this repository is not enough. The compiled `roamresearch` plugin binary must exist at: + +```text +~/.op/plugins/local/roamresearch +``` + +Only after that should this work: + +```bash +op plugin list | grep roam-cli +op plugin init roam-cli +``` + +## Shell Wrapper Behavior + +After initialization, 1Password writes a shell wrapper similar to: + +```bash +roam-cli() { + op plugin run -- roam-cli "$@" +} +``` + +This shadows the real `roam-cli` binary in the interactive shell. It should not recurse because `op plugin run` is a separate process and resolves the real executable from `PATH`, not from the shell function. + +Users can bypass the wrapper by calling the binary with an absolute path: + +```bash +/usr/local/bin/roam-cli status +``` + +## User Flows + +### Release user + +The release archive must contain both binaries: + +```text +roam-cli +roamresearch +``` + +User flow: + +```bash +# Install both binaries by the normal release install path. +roam-cli --version + +# Copy the local 1Password plugin to ~/.op/plugins/local/roamresearch. +roam-cli onepassword install + +# Confirm op can discover it. +op plugin list | grep roam-cli + +# Let 1Password create the shell wrapper. +op plugin init roam-cli + +# Reload shell plugin aliases. +source ~/.config/op/plugins.sh + +# Run normally. +roam-cli status +``` + +### Source checkout / developer + +```bash +make op-plugin-build +roam-cli onepassword install --from ./bin/roamresearch +op plugin list | grep roam-cli +op plugin init roam-cli +``` + +### Dotfile-managed shell wrapper + +Users who do not want `op plugin init` to edit `~/.config/op/plugins.sh` can add this manually: + +```bash +roam-cli() { + op plugin run -- roam-cli "$@" +} + +export OP_PLUGIN_ALIASES_SOURCED=1 +``` + +## Repository Changes + +Implement the plugin as a nested Go module: + +```text +contrib/1password-plugin/ + go.mod + go.sum + main.go + plugin.go + credential.go + executable.go + credential_test.go + plugin_test.go +``` + +Update the main CLI: + +```text +internal/cmd/onepassword.go +internal/cmd/root.go +internal/cmd/onepassword_test.go +``` + +Update build/docs: + +```text +Makefile +README.md +docs/help/topics/configuration.md +``` + +Do not add a runtime dependency on repository-local scripts. Installed users may only have binaries, not the source checkout. + +## Plugin Module Setup + +Create the nested module with: + +```bash +mkdir -p contrib/1password-plugin +cd contrib/1password-plugin +go mod init github.com/Leechael/roam-cli/contrib/1password-plugin +go get github.com/1Password/shell-plugins@af9327a +go mod tidy +``` + +The exact pseudo-version written to `go.mod` may differ after `go get`. Keep the generated `go.mod` and `go.sum`. + +## Plugin Schema + +Credential type: `API Token` + +Use these field names exactly: + +```go +const ( + fieldToken = sdk.FieldName("Token") + fieldGraph = sdk.FieldName("Graph") + fieldAPIURL = sdk.FieldName("API URL") + fieldTimeoutSeconds = sdk.FieldName("Timeout Seconds") +) +``` + +Do not use non-existent constants such as `fieldname.Graph` or `fieldname.TimeoutSeconds`. + +Environment mapping: + +```go +var envVarMapping = map[string]sdk.FieldName{ + "ROAM_API_TOKEN": fieldToken, + "ROAM_API_GRAPH": fieldGraph, + "ROAM_API_BASE_URL": fieldAPIURL, + "ROAM_TIMEOUT_SECONDS": fieldTimeoutSeconds, +} +``` + +Fields: + +| Field | Env var | Secret | Optional | +|---|---|---:|---:| +| Token | `ROAM_API_TOKEN` | yes | no | +| Graph | `ROAM_API_GRAPH` | no | no | +| API URL | `ROAM_API_BASE_URL` | no | yes | +| Timeout Seconds | `ROAM_TIMEOUT_SECONDS` | no | yes | + +Credential implementation rules: + +- `Token` is the only secret field. +- `Graph` is required because `roam-cli` fails without it. +- `API URL` and `Timeout Seconds` are optional. +- Use `provision.EnvVars(envVarMapping)` as `DefaultProvisioner`. +- Use `importer.TryEnvVarPair(envVarMapping)` as `Importer`. + +Executable schema: + +```go +schema.Executable{ + Name: "Roam Research CLI", + Runs: []string{"roam-cli"}, + DocsURL: sdk.URL("https://github.com/Leechael/roam-cli"), + NeedsAuth: needsauth.IfAll( + needsauth.NotForHelpOrVersion(), + needsauth.NotWithoutArgs(), + ), + Uses: []schema.CredentialUsage{ + {Name: credname.APIToken}, + }, +} +``` + +Plugin schema: + +```go +schema.Plugin{ + Name: "roamresearch", + Platform: schema.PlatformInfo{ + Name: "Roam Research", + Homepage: sdk.URL("https://roamresearch.com"), + }, + Credentials: []schema.CredentialType{APIToken()}, + Executables: []schema.Executable{RoamCLI()}, +} +``` + +## Plugin Binary RPC Server + +`contrib/1password-plugin/main.go` must start a 1Password shell plugin RPC server. A schema-only `main.go` is not enough. + +Use this structure: + +```go +package main + +import ( + "github.com/1Password/shell-plugins/sdk/rpc/proto" + "github.com/1Password/shell-plugins/sdk/rpc/server" + "github.com/1Password/shell-plugins/sdk/schema" + "github.com/hashicorp/go-plugin" +) + +func main() { + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: plugin.HandshakeConfig{ + ProtocolVersion: proto.Version, + MagicCookieKey: proto.MagicCookieKey, + MagicCookieValue: proto.MagicCookieValue, + }, + Plugins: plugin.PluginSet{ + "plugin": &server.RPCPlugin{RPCPlugin: func() (schema.Plugin, error) { + return New(), nil + }}, + }, + }) +} +``` + +## Main CLI Install Command + +Add `roam-cli onepassword install`. + +Flags: + +```text +--from PATH Copy plugin binary from PATH instead of auto-discovery +--force Replace existing ~/.op/plugins/local/roamresearch +``` + +Behavior: + +1. If `op` is not in `PATH`, return an error that asks the user to install 1Password CLI. +2. Resolve the source plugin binary: + 1. if `--from` is set, use that path + 2. otherwise look for `roamresearch` in the same directory as the running `roam-cli` binary + 3. otherwise look for `roamresearch` in `PATH` + 4. otherwise fail with a message that the release must include `roamresearch` +3. Verify the source path exists and is executable. +4. Create these directories if missing: + - `~/.op` + - `~/.op/plugins` + - `~/.op/plugins/local` +5. Set directory permissions to `0700` where possible. +6. If `~/.op/plugins/local/roamresearch` exists and `--force` is not set, return an error explaining `--force`. +7. Copy the binary to `~/.op/plugins/local/roamresearch`. +8. Set destination mode to `0755`. +9. Print exactly these next steps: + +```text +Installed 1Password shell plugin: ~/.op/plugins/local/roamresearch +Next steps: + op plugin list | grep roam-cli + op plugin init roam-cli + source ~/.config/op/plugins.sh +``` + +Do not run `op plugin init` automatically. It mutates user shell configuration. + +Do not print secret values or inspect user 1Password items. + +## Makefile Changes + +Add these variables: + +```make +OP_PLUGIN_NAME := roamresearch +OP_PLUGIN_DIR := contrib/1password-plugin +OP_PLUGIN_BIN := $(BIN_DIR)/$(OP_PLUGIN_NAME) +``` + +Add these targets: + +```make +op-plugin-test: + cd $(OP_PLUGIN_DIR) && go test ./... -count=1 + +op-plugin-build: + mkdir -p $(BIN_DIR) + cd $(OP_PLUGIN_DIR) && go build -o ../../$(OP_PLUGIN_BIN) . + +op-plugin-install-local: op-plugin-build + mkdir -p ~/.op/plugins/local + chmod 700 ~/.op ~/.op/plugins ~/.op/plugins/local + cp $(OP_PLUGIN_BIN) ~/.op/plugins/local/$(OP_PLUGIN_NAME) + chmod 755 ~/.op/plugins/local/$(OP_PLUGIN_NAME) +``` + +Update `ci` to include `op-plugin-test`. + +Update `clean` to remove `$(OP_PLUGIN_BIN)` through the existing `rm -rf $(BIN_DIR)`. + +## Release Packaging + +Change release archives so each OS/arch archive contains both binaries: + +```text +roam-cli +roamresearch +``` + +Artifact naming: + +```text +roam-cli__.tar.gz +``` + +For each target OS/arch: + +1. build main binary as `roam-cli` +2. build plugin binary as `roamresearch` +3. tar both files into one archive + +Do not publish a standalone plugin-only archive as the primary user path. The install command depends on finding `roamresearch` next to `roam-cli` after normal installation. + +If the release workflow cannot bundle both binaries in the first implementation, block and ask before choosing a different distribution path. + +## Tests + +### Plugin tests + +In `contrib/1password-plugin`: + +- provisioner maps fields to the four expected env vars +- importer reads the four expected env vars +- needs-auth returns false for: + - no args + - `--help` + - `help` + - `--version` +- needs-auth returns true for: + - `status` + - `get --today` +- schema deep validation has no errors + +Run: + +```bash +cd contrib/1password-plugin && go test ./... -count=1 +``` + +### Main CLI tests + +In `internal/cmd/onepassword_test.go`: + +- missing source binary returns a clear error +- existing destination without `--force` returns a clear error +- `--from` copies to a temp fake OP config directory +- installed file mode is executable + +Implementation note: do not write tests to the real `~/.op`. Add a small helper that accepts an overridable config dir, or use an unexported package variable reset with `t.Cleanup`. + +### Full local manual test + +Only run this on a machine with 1Password CLI installed: + +```bash +make build op-plugin-build +./bin/roam-cli onepassword install --from ./bin/roamresearch --force +op plugin list | grep roam-cli +op plugin init roam-cli +op plugin run -- roam-cli --help +``` + +Real credential test is manual because it requires a valid 1Password item and Roam token: + +```bash +op plugin run -- roam-cli status +``` + +Do not put the real credential test in CI. + +## Documentation Updates + +Update `README.md` and `docs/help/topics/configuration.md` with two supported 1Password paths. + +Path A: simple existing `op run` usage: + +```bash +op run --env-file=.env -- roam-cli status +``` + +Path B: shell plugin usage: + +```bash +roam-cli onepassword install +op plugin init roam-cli +source ~/.config/op/plugins.sh +roam-cli status +``` + +Explain that `op plugin init roam-cli` only works after `roam-cli onepassword install` has copied the local plugin binary. + +## Implementation Order + +Follow this order exactly: + +1. Add `contrib/1password-plugin` module and plugin schema. +2. Add plugin tests and make `cd contrib/1password-plugin && go test ./...` pass. +3. Add `Makefile` targets: `op-plugin-test`, `op-plugin-build`, `op-plugin-install-local`. +4. Add `roam-cli onepassword install` command and tests. +5. Update root `ci` target to include plugin tests. +6. Update docs. +7. Update release packaging. +8. Run final validation. + +Do not change the existing Roam API client auth behavior unless a test proves it is required. + +## Final Validation + +Before reporting completion, run: + +```bash +go test ./... -count=1 +cd contrib/1password-plugin && go test ./... -count=1 +make op-plugin-build +prek +``` + +If `op` is installed locally, also run: + +```bash +./bin/roam-cli onepassword install --from ./bin/roamresearch --force +op plugin list | grep roam-cli +op plugin run -- roam-cli --help +``` diff --git a/internal/cmd/onepassword.go b/internal/cmd/onepassword.go new file mode 100644 index 0000000..0dcf510 --- /dev/null +++ b/internal/cmd/onepassword.go @@ -0,0 +1,208 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +// execLookPath is overridable in tests. +var execLookPath = exec.LookPath + +const ( + localPluginDir = "/.op/plugins/local" + localPluginPath = "~/.op/plugins/local/roamresearch" + localPluginBinary = "roamresearch" +) + +type onePasswordOptions struct { + from string + force bool +} + +var opOpts onePasswordOptions + +// defaultOPPluginDir returns the default local plugin directory. +// Override in tests by setting testOPPluginDir. +var testOPPluginDir string + +func opPluginDir() string { + if testOPPluginDir != "" { + return testOPPluginDir + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home + localPluginDir +} + +func newOnePasswordCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "onepassword", + Short: "Manage 1Password shell plugin integration", + } + + installCmd := &cobra.Command{ + Use: "install", + Short: "Install the 1Password shell plugin for roam-cli", + Long: `Install the 1Password shell plugin binary to the local plugins directory. + +This copies the roamresearch plugin binary to ~/.op/plugins/local/roamresearch +so that 1Password CLI can discover it. + +After installation, run: + op plugin list | grep roam-cli + op plugin init roam-cli + source ~/.config/op/plugins.sh`, + RunE: runOnePasswordInstall, + } + + installCmd.Flags().StringVar(&opOpts.from, "from", "", "Copy plugin binary from PATH instead of auto-discovery") + installCmd.Flags().BoolVar(&opOpts.force, "force", false, "Replace existing ~/.op/plugins/local/roamresearch") + + cmd.AddCommand(installCmd) + return cmd +} + +func findOpBinary() (string, error) { + path, err := execLookPath("op") + if err != nil { + return "", fmt.Errorf("1Password CLI (op) not found in PATH; install it from https://1password.com/downloads/command-line") + } + return path, nil +} + +func resolvePluginSource(fromFlag string) (string, error) { + if fromFlag != "" { + info, err := os.Stat(fromFlag) + if err != nil { + return "", fmt.Errorf("--from path %q does not exist", fromFlag) + } + if info.Mode()&0o111 == 0 { + return "", fmt.Errorf("--from path %q is not executable", fromFlag) + } + return fromFlag, nil + } + + // Look next to the running binary. + exe, err := os.Executable() + if err == nil { + candidate := filepath.Join(filepath.Dir(exe), "roamresearch") + if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + + // Look in PATH. + candidate, err := execLookPath("roamresearch") + if err == nil { + return candidate, nil + } + + return "", fmt.Errorf("roamresearch plugin binary not found; place it next to roam-cli or in PATH, or use --from") +} + +func runOnePasswordInstall(cmd *cobra.Command, args []string) error { + if _, err := findOpBinary(); err != nil { + return err + } + + src, err := resolvePluginSource(opOpts.from) + if err != nil { + return err + } + + destDir := opPluginDir() + if destDir == "" { + return fmt.Errorf("cannot determine home directory") + } + dest := filepath.Join(destDir, localPluginBinary) + + if err := ensureLocalPluginDirs(destDir); err != nil { + return err + } + + if err := validatePluginDestination(dest, opOpts.force); err != nil { + return err + } + + if err := copyFile(src, dest); err != nil { + return fmt.Errorf("cannot copy plugin binary: %w", err) + } + + if err := os.Chmod(dest, 0755); err != nil { + return fmt.Errorf("cannot set permissions on %s: %w", dest, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Installed 1Password shell plugin: %s\n", localPluginPath) + fmt.Fprintln(cmd.OutOrStdout(), "Next steps:") + fmt.Fprintln(cmd.OutOrStdout(), " op plugin list | grep roam-cli") + fmt.Fprintln(cmd.OutOrStdout(), " op plugin init roam-cli") + fmt.Fprintln(cmd.OutOrStdout(), " source ~/.config/op/plugins.sh") + + return nil +} + +func ensureLocalPluginDirs(destDir string) error { + if err := os.MkdirAll(destDir, 0700); err != nil { + return fmt.Errorf("cannot create plugin directory %s: %w", destDir, err) + } + + for _, dir := range []string{filepath.Dir(filepath.Dir(destDir)), filepath.Dir(destDir), destDir} { + if err := os.Chmod(dir, 0700); err != nil { + return fmt.Errorf("cannot set permissions on %s: %w", dir, err) + } + } + + return nil +} + +func validatePluginDestination(dest string, force bool) error { + info, err := os.Lstat(dest) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("cannot inspect %s: %w", dest, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink; refusing to overwrite", dest) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", dest) + } + if !force { + return fmt.Errorf("%s already exists; use --force to overwrite", dest) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + if err != nil { + return err + } + return out.Close() +} + +// RegisterOnepasswordCmd adds the onepassword subcommand to the given root command. +func RegisterOnepasswordCmd(root *cobra.Command) { + root.AddCommand(newOnePasswordCmd()) +} diff --git a/internal/cmd/onepassword_test.go b/internal/cmd/onepassword_test.go new file mode 100644 index 0000000..63bcd61 --- /dev/null +++ b/internal/cmd/onepassword_test.go @@ -0,0 +1,302 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// setupTestPluginDir creates a temp directory structure mimicking ~/.op/plugins/local. +func setupTestPluginDir(t *testing.T) (string, func()) { + t.Helper() + tmpDir := t.TempDir() + pluginDir := filepath.Join(tmpDir, ".op", "plugins", "local") + err := os.MkdirAll(pluginDir, 0700) + if err != nil { + t.Fatal(err) + } + old := testOPPluginDir + testOPPluginDir = pluginDir + return tmpDir, func() { testOPPluginDir = old } +} + +// writeTempPluginBinary writes an executable file for use as a source plugin binary. +func writeTempPluginBinary(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + content := []byte("#!/bin/sh\necho mock-plugin\n") + if err := os.WriteFile(path, content, 0755); err != nil { + t.Fatal(err) + } + return path +} + +// mockOpLookup replaces execLookPath so that "op" is always found. +func mockOpLookup() func() { + orig := execLookPath + execLookPath = func(name string) (string, error) { + if name == "op" { + return "/usr/local/bin/op", nil + } + return "", os.ErrNotExist + } + return func() { execLookPath = orig } +} + +func TestOnePasswordInstall_MissingSourceBinary(t *testing.T) { + _, cleanup := setupTestPluginDir(t) + defer cleanup() + defer mockOpLookup()() + + cmd := newOnePasswordCmd() + installCmd := cmd.Commands()[0] + opOpts.from = "" + opOpts.force = false + + err := installCmd.RunE(installCmd, nil) + if err == nil { + t.Fatal("expected error for missing source binary") + } + if !strings.Contains(err.Error(), "roamresearch plugin binary not found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestOnePasswordInstall_ExistingDestinationWithoutForce(t *testing.T) { + _, cleanup := setupTestPluginDir(t) + defer cleanup() + defer mockOpLookup()() + + // Create an existing plugin in the destination. + destPath := filepath.Join(testOPPluginDir, "roamresearch") + if err := os.WriteFile(destPath, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + + // Create a source binary. + srcDir := t.TempDir() + srcPath := writeTempPluginBinary(t, srcDir, "roamresearch") + + installCmd := newOnePasswordCmd().Commands()[0] + opOpts.from = srcPath + opOpts.force = false + + err := installCmd.RunE(installCmd, nil) + if err == nil { + t.Fatal("expected error for existing destination without --force") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestOnePasswordInstall_WithFromFlag(t *testing.T) { + _, cleanup := setupTestPluginDir(t) + defer cleanup() + defer mockOpLookup()() + + srcDir := t.TempDir() + srcPath := writeTempPluginBinary(t, srcDir, "roamresearch") + + installCmd := newOnePasswordCmd().Commands()[0] + opOpts.from = srcPath + opOpts.force = false + + buf := &strings.Builder{} + installCmd.SetOut(buf) + + err := installCmd.RunE(installCmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify destination exists and is executable. + destPath := filepath.Join(testOPPluginDir, "roamresearch") + info, err := os.Stat(destPath) + if err != nil { + t.Fatalf("destination not found: %v", err) + } + if info.Mode()&0o100 == 0 { + t.Error("destination binary is not executable") + } + + expectedOutput := "Installed 1Password shell plugin: ~/.op/plugins/local/roamresearch\n" + + "Next steps:\n" + + " op plugin list | grep roam-cli\n" + + " op plugin init roam-cli\n" + + " source ~/.config/op/plugins.sh\n" + if output := buf.String(); output != expectedOutput { + t.Errorf("unexpected output:\n%s", output) + } +} + +func TestOnePasswordInstall_SetsLocalPluginDirectoryPermissions(t *testing.T) { + tmpDir := t.TempDir() + opDir := filepath.Join(tmpDir, ".op") + pluginsDir := filepath.Join(opDir, "plugins") + pluginDir := filepath.Join(pluginsDir, "local") + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + for _, dir := range []string{opDir, pluginsDir, pluginDir} { + if err := os.Chmod(dir, 0755); err != nil { + t.Fatal(err) + } + } + + old := testOPPluginDir + testOPPluginDir = pluginDir + defer func() { testOPPluginDir = old }() + defer mockOpLookup()() + + srcDir := t.TempDir() + srcPath := writeTempPluginBinary(t, srcDir, "roamresearch") + + installCmd := newOnePasswordCmd().Commands()[0] + opOpts.from = srcPath + opOpts.force = false + + if err := installCmd.RunE(installCmd, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, dir := range []string{opDir, pluginsDir, pluginDir} { + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0700 { + t.Errorf("%s mode = %o, want 700", dir, got) + } + } +} + +func TestOnePasswordInstall_WithForceOverwrites(t *testing.T) { + _, cleanup := setupTestPluginDir(t) + defer cleanup() + defer mockOpLookup()() + + // Create existing plugin. + destPath := filepath.Join(testOPPluginDir, "roamresearch") + if err := os.WriteFile(destPath, []byte("old-contents"), 0644); err != nil { + t.Fatal(err) + } + + srcDir := t.TempDir() + srcPath := writeTempPluginBinary(t, srcDir, "roamresearch") + + installCmd := newOnePasswordCmd().Commands()[0] + opOpts.from = srcPath + opOpts.force = true + + err := installCmd.RunE(installCmd, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + info, err := os.Stat(destPath) + if err != nil { + t.Fatalf("destination not found after overwrite: %v", err) + } + if info.Mode()&0o100 == 0 { + t.Error("destination binary is not executable") + } +} + +func TestOnePasswordInstall_WithForceRejectsSymlinkDestination(t *testing.T) { + _, cleanup := setupTestPluginDir(t) + defer cleanup() + defer mockOpLookup()() + + targetPath := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(targetPath, []byte("target"), 0644); err != nil { + t.Fatal(err) + } + destPath := filepath.Join(testOPPluginDir, "roamresearch") + if err := os.Symlink(targetPath, destPath); err != nil { + t.Fatal(err) + } + + srcDir := t.TempDir() + srcPath := writeTempPluginBinary(t, srcDir, "roamresearch") + + installCmd := newOnePasswordCmd().Commands()[0] + opOpts.from = srcPath + opOpts.force = true + + err := installCmd.RunE(installCmd, nil) + if err == nil { + t.Fatal("expected error for symlink destination") + } + if !strings.Contains(err.Error(), "symlink") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolvePluginSource_WithFromFlag(t *testing.T) { + dir := t.TempDir() + path := writeTempPluginBinary(t, dir, "roamresearch") + + result, err := resolvePluginSource(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != path { + t.Errorf("expected %q, got %q", path, result) + } +} + +func TestResolvePluginSource_AcceptsAnyExecuteBit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "roamresearch") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho mock-plugin\n"), 0010); err != nil { + t.Fatal(err) + } + + result, err := resolvePluginSource(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != path { + t.Errorf("expected %q, got %q", path, result) + } +} + +func TestResolvePluginSource_NonexistentFrom(t *testing.T) { + _, err := resolvePluginSource("/nonexistent/path") + if err == nil { + t.Fatal("expected error for nonexistent --from path") + } +} + +func TestOnePasswordHelp(t *testing.T) { + cmd := newOnePasswordCmd() + buf := &strings.Builder{} + cmd.SetOut(buf) + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Fatal(err) + } + output := buf.String() + if !strings.Contains(output, "install") { + t.Errorf("help output missing install subcommand: %s", output) + } +} + +func TestFindOpBinary_MissingInPATH(t *testing.T) { + orig := execLookPath + execLookPath = func(name string) (string, error) { + return "", os.ErrNotExist + } + defer func() { execLookPath = orig }() + + _, err := findOpBinary() + if err == nil { + t.Fatal("expected error when op is not in PATH") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 09dd0a9..10e9196 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -66,6 +66,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newStatusCmd()) root.AddCommand(newGetCmd()) + RegisterOnepasswordCmd(root) root.AddCommand(newSearchCmd()) root.AddCommand(newSearchPagesCmd()) root.AddCommand(newQCmd()) diff --git a/internal/cmd/testdata/root-help.golden b/internal/cmd/testdata/root-help.golden index 209f136..ba3f604 100644 --- a/internal/cmd/testdata/root-help.golden +++ b/internal/cmd/testdata/root-help.golden @@ -10,6 +10,7 @@ Available Commands: get Get page by title or block by uid help Help about any command, help topic, or example category journal Get journaling blocks from Daily Notes + onepassword Manage 1Password shell plugin integration q Execute raw datalog query save Save markdown as a Roam page or under a parent block search Search blocks containing all terms @@ -27,6 +28,7 @@ Flags: Use "roam-cli [command] --help" for more information about a command. HELP TOPICS + configuration `roam-cli` reads credentials from environment variables: datalog Roam Research exposes a Datomic-flavored Datalog query API with Clojure built-in functions. exit-codes Stable exit codes for scripting and automation.