The CLI now covers the whole Redmine 507 surface: `search TERM [-page N]` (operators pass through quoted), `messages send -title -to user1,user2 [-group/-email] [-file|-raw]`, plus the two secret helpers `webhook verify SIG` (delivery body on stdin, exit 0 only on a valid HMAC) and `sso verify SSO SIG` (prints the decoded Discourse Connect payload as JSON). The helpers run before client construction so they work with only their secret env set, no instance credentials needed. Usage text lists the new env vars. Part of Redmine 507 (Discourse Go client).
128 lines
3.7 KiB
Go
128 lines
3.7 KiB
Go
// Command discourse-go is a thin CLI over the client library: enough to
|
|
// drive and verify a Discourse instance by hand (categories, topics,
|
|
// posts, raw passthrough). Credentials arrive via environment only
|
|
// (DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME, see
|
|
// env.example) — never as flags, so they never land in shell history or
|
|
// process listings.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"git.knownelement.com/ukrrs/mopac-discourse-go"
|
|
)
|
|
|
|
const usage = `discourse-go — thin Discourse client CLI (config via env)
|
|
|
|
Usage:
|
|
discourse-go whoami
|
|
discourse-go categories list
|
|
discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]...
|
|
discourse-go topics list [-category ID] [-slug SLUG] [-latest]
|
|
discourse-go topics show ID
|
|
discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT]
|
|
discourse-go posts create -topic ID [-file PATH | -raw TEXT]
|
|
discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT]
|
|
discourse-go search TERM [-page N]
|
|
discourse-go messages send -title TITLE -to USER[,USER...] [-file PATH | -raw TEXT]
|
|
discourse-go webhook verify SIG (delivery body on stdin)
|
|
discourse-go sso verify SSO SIG (Discourse Connect payload check)
|
|
discourse-go raw METHOD PATH [-data JSON]
|
|
|
|
Environment (0600 env file, sourced before the call):
|
|
DISCOURSE_URL instance root, e.g. https://community.turnsys.com
|
|
DISCOURSE_API_KEY API key (DISCOURSE_KEY works too; never a flag, never logged)
|
|
DISCOURSE_API_USERNAME the user the key acts as (default: system)
|
|
DISCOURSE_WEBHOOK_SECRET webhook signature secret (webhook verify)
|
|
DISCOURSE_SSO_SECRET Discourse Connect secret (sso verify)
|
|
|
|
Perm levels: 1 = reply/see, 2 = create posts, 3 = full.
|
|
raw PATH is everything after the instance root, e.g. /groups.json.
|
|
Exit codes: 0 ok · 1 usage/config · 2 API/transport error (message says
|
|
which class: forbidden/unauthorized/not-found/rate-limited/server/
|
|
unreachable/malformed).`
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:]))
|
|
}
|
|
|
|
func run(args []string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
return 1
|
|
}
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// Secret-helper commands never touch an instance: run them before
|
|
// client construction so they work without DISCOURSE_URL/DISCOURSE_API_KEY.
|
|
switch args[0] {
|
|
case "webhook":
|
|
return runWebhook(args[1:])
|
|
case "sso":
|
|
return runSSO(args[1:])
|
|
}
|
|
|
|
client, err := discourse.NewFromEnv()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
var out any
|
|
switch args[0] {
|
|
case "whoami":
|
|
if len(args) != 1 {
|
|
return usageErr()
|
|
}
|
|
out, err = client.CurrentUser(ctx)
|
|
|
|
case "categories":
|
|
out, err = runCategories(ctx, client, args[1:])
|
|
case "topics":
|
|
out, err = runTopics(ctx, client, args[1:])
|
|
case "posts":
|
|
out, err = runPosts(ctx, client, args[1:])
|
|
case "search":
|
|
out, err = runSearch(ctx, client, args[1:])
|
|
case "messages":
|
|
out, err = runMessages(ctx, client, args[1:])
|
|
case "raw":
|
|
out, err = runRaw(ctx, client, args[1:])
|
|
case "help", "-h", "--help":
|
|
fmt.Print(usage)
|
|
return 0
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "discourse-go: unknown command %q\n\n%s", args[0], usage)
|
|
return 1
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err)
|
|
if errors.Is(err, discourse.ErrInvalidRequest) {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
if out == nil {
|
|
return 0
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(out); err != nil {
|
|
fmt.Fprintf(os.Stderr, "discourse-go: encode output: %v\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func usageErr() int {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
return 1
|
|
}
|