Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c6c0549
feat(cli): add schema, details, and storage support
elibosley Jan 25, 2026
ae17b60
fix(cli): avoid lookahead regex for url detection
elibosley Jan 25, 2026
6a5157f
test(cli): expand storage parity coverage
elibosley Jan 25, 2026
2ee5c43
fix(cli): correct storage action prefix check
elibosley Jan 25, 2026
1191e3f
fix(cli): honor explicit storage path over env
elibosley Jan 25, 2026
5983e87
test(cli): cover storage path precedence
elibosley Jan 25, 2026
d172d0a
Update AGENTS.md
elibosley Jan 25, 2026
b9e2d35
feat: pr work
elibosley Jan 26, 2026
e075857
feat: more updates
elibosley Jan 26, 2026
f67af89
feat: parity confirmed
elibosley Jan 26, 2026
ce81e75
feat: schema improvements
elibosley Jan 27, 2026
0fcd17c
fix: more tweaks
elibosley Jan 27, 2026
0a4ed0f
feat: add parity test providers
elibosley Jan 27, 2026
f075e89
feat: actual E2E testing
elibosley Jan 27, 2026
d3b5cf8
fix: add exercise schema changes
elibosley Jan 27, 2026
c27e254
feat: more fixes
elibosley Jan 27, 2026
44b8837
fix: more e2e test fixes
elibosley Jan 27, 2026
79d30c6
feat: more provider parity
elibosley Jan 27, 2026
aded779
feat: more notification endpoint helper fixes
elibosley Jan 27, 2026
f9bcf4e
feat: e2e parity passing
elibosley Jan 27, 2026
b204f2a
Enable parallel parity tests and add schema caching
elibosley Jan 27, 2026
a3fd86a
feat: more E2E integrations
elibosley Jan 28, 2026
893194d
Add FCM OAuth2 flow and test harness updates
elibosley Jan 28, 2026
a2c833d
Remove token field from FCM OAuth payload
elibosley Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@

# Local tooling
/.venv/
/.gocache/
/.tmp/pycapture/
/.tmp/pycases/
__pycache__/
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ Environment
- CLI binary name: `apprise` (drop-in goal)

Workflow
- Use Graphite (`gt`) for stacked PRs.
- Tests should compare Go behavior to the installed Python apprise using a local capture server.
- Request-spec parity is driven by provider folders in `internal/parity/providers/<provider>` with `manifest.json` and `cases.json`.
- Keep Go version strings aligned with upstream apprise (see `internal/version/version.go`).
Expand Down
4 changes: 4 additions & 0 deletions PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,14 @@ Test notes
- Parity test runs fix time/nonce/JWT inputs via env defaults to keep AWS/OAuth/VAPID fixtures deterministic.
- Parity tests compare full request sequences by running Go `Send` implementations and capturing all outgoing HTTP requests; request comparisons include method, URL (including query), headers, and body (JSON/form bodies are normalized). Use `-v` to see per-case progress output.
- Capture stubs return canned responses for multi-request providers (e.g., SendPulse OAuth, Emby login/sessions); parity focuses on outbound request shape, not response payloads.
- Python capture caching uses `.tmp/pycapture` by default; set `APPRISE_CAPTURE_CACHE=0` to disable or `APPRISE_CAPTURE_CACHE_DIR` to override.
- Schema case caching uses `.tmp/pycases` by default; set `APPRISE_CASES_CACHE=0` to disable or `APPRISE_CASES_CACHE_DIR` to override.
- Golden fixtures (`internal/parity/providers/<provider>/golden.json`) enable Python-free parity checks; regenerate with `internal/testutil/scripts/update_golden.py`.
- Golden refresh example: `.venv/bin/python internal/testutil/scripts/update_golden.py`.
- CI parity setup uses `scripts/ci/setup_parity_env.sh` and `scripts/ci/run_parity_tests.sh`.
- Always run `go` commands with `GOCACHE=$PWD/.gocache` in this repo to avoid sandboxed cache permission errors.
- Running `go test` in sandboxed environments may require `GOCACHE` set to a writable path and capture-server tests may need local listen permissions.
- Parity subtests run in parallel by default; set `APPRISE_PARITY_SERIAL=1` to force serial execution.

Notes
- Version updates: edit `internal/version/version.go` or override at build time with:
Expand Down
286 changes: 250 additions & 36 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"strconv"
"strings"

"github.com/unraid/apprise-go/internal/notify"
Expand All @@ -17,34 +18,112 @@ const usageText = "" +
" apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]\n" +
" apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]\n"

type cliOptions struct {
body string
title string
notificationType string
inputFormat string
disableAsync bool
showVersion bool
showHelp bool
showSchema bool
showDetails bool
dryRun bool
debug bool
verbose int
recursionDepth int
interpretEscapes bool
interpretEmojis bool
theme string
configPaths []string
attachments []string
pluginPaths []string
tags []string
storagePath string
storagePruneDays int
storageUIDLength int
storageMode string
}

type stringSliceFlag []string

func (s *stringSliceFlag) String() string {
return strings.Join(*s, ",")
}

func (s *stringSliceFlag) Set(value string) error {
*s = append(*s, value)
return nil
}

type countFlag int

func (c *countFlag) String() string {
return strconv.Itoa(int(*c))
}

func (c *countFlag) Set(value string) error {
*c++
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func (c *countFlag) IsBoolFlag() bool {
return true
}

func Run(args []string, stdout, stderr io.Writer) int {
opts := defaultCliOptions()
args = normalizeArgs(args)
fs := flag.NewFlagSet("apprise", flag.ContinueOnError)
fs.SetOutput(stderr)

var (
body string
title string
notificationType string
inputFormat string
disableAsync bool
showVersion bool
showHelp bool
)

fs.StringVar(&body, "body", "", "Specify the message body.")
fs.StringVar(&body, "b", "", "Specify the message body.")
fs.StringVar(&title, "title", "", "Specify the message title.")
fs.StringVar(&title, "t", "", "Specify the message title.")
fs.StringVar(&notificationType, "notification-type", string(notify.NotifyInfo), "Specify the message type.")
fs.StringVar(&notificationType, "n", string(notify.NotifyInfo), "Specify the message type.")
fs.StringVar(&inputFormat, "input-format", "text", "Specify the message input format.")
fs.StringVar(&inputFormat, "i", "text", "Specify the message input format.")
fs.BoolVar(&disableAsync, "disable-async", false, "Send all notifications sequentially.")
fs.BoolVar(&disableAsync, "Da", false, "Send all notifications sequentially.")
fs.BoolVar(&showVersion, "version", false, "Display the apprise version and exit.")
fs.BoolVar(&showVersion, "V", false, "Display the apprise version and exit.")
fs.BoolVar(&showHelp, "help", false, "Show help.")
fs.BoolVar(&showHelp, "h", false, "Show help.")
fs.StringVar(&opts.body, "body", "", "Specify the message body.")
fs.StringVar(&opts.body, "b", "", "Specify the message body.")
fs.StringVar(&opts.title, "title", "", "Specify the message title.")
fs.StringVar(&opts.title, "t", "", "Specify the message title.")
fs.StringVar(&opts.notificationType, "notification-type", opts.notificationType, "Specify the message type.")
fs.StringVar(&opts.notificationType, "n", opts.notificationType, "Specify the message type.")
fs.StringVar(&opts.inputFormat, "input-format", opts.inputFormat, "Specify the message input format.")
fs.StringVar(&opts.inputFormat, "i", opts.inputFormat, "Specify the message input format.")
fs.BoolVar(&opts.disableAsync, "disable-async", false, "Send all notifications sequentially.")
fs.BoolVar(&opts.disableAsync, "Da", false, "Send all notifications sequentially.")
fs.BoolVar(&opts.dryRun, "dry-run", false, "Perform a trial run without sending notifications.")
fs.BoolVar(&opts.dryRun, "d", false, "Perform a trial run without sending notifications.")
fs.BoolVar(&opts.showDetails, "details", false, "Prints details about the current services supported by Apprise.")
fs.BoolVar(&opts.showDetails, "l", false, "Prints details about the current services supported by Apprise.")
fs.BoolVar(&opts.showSchema, "schema", false, "Prints Apprise schema JSON and exits.")
fs.IntVar(&opts.recursionDepth, "recursion-depth", opts.recursionDepth, "Specify the recursion depth when loading configs.")
fs.IntVar(&opts.recursionDepth, "R", opts.recursionDepth, "Specify the recursion depth when loading configs.")
fs.Var((*countFlag)(&opts.verbose), "v", "Increase verbosity.")
fs.Var((*countFlag)(&opts.verbose), "verbose", "Increase verbosity.")
fs.BoolVar(&opts.interpretEscapes, "interpret-escapes", false, "Enable interpretation of backslash escapes.")
fs.BoolVar(&opts.interpretEscapes, "e", false, "Enable interpretation of backslash escapes.")
fs.BoolVar(&opts.interpretEmojis, "interpret-emojis", false, "Enable interpretation of :emoji: definitions.")
fs.BoolVar(&opts.interpretEmojis, "j", false, "Enable interpretation of :emoji: definitions.")
fs.BoolVar(&opts.debug, "debug", false, "Debug mode.")
fs.BoolVar(&opts.debug, "D", false, "Debug mode.")
fs.StringVar(&opts.theme, "theme", opts.theme, "Specify the default theme.")
fs.StringVar(&opts.theme, "T", opts.theme, "Specify the default theme.")
fs.Var((*stringSliceFlag)(&opts.tags), "tag", "Specify tags used to filter which services to notify.")
fs.Var((*stringSliceFlag)(&opts.tags), "g", "Specify tags used to filter which services to notify.")
fs.Var((*stringSliceFlag)(&opts.configPaths), "config", "Specify one or more configuration locations.")
fs.Var((*stringSliceFlag)(&opts.configPaths), "c", "Specify one or more configuration locations.")
fs.Var((*stringSliceFlag)(&opts.attachments), "attach", "Specify one or more attachments.")
fs.Var((*stringSliceFlag)(&opts.attachments), "a", "Specify one or more attachments.")
fs.Var((*stringSliceFlag)(&opts.pluginPaths), "plugin-path", "Specify one or more plugin paths to scan.")
fs.Var((*stringSliceFlag)(&opts.pluginPaths), "P", "Specify one or more plugin paths to scan.")
fs.StringVar(&opts.storagePath, "storage-path", opts.storagePath, "Specify the path to the persistent storage location.")
fs.StringVar(&opts.storagePath, "S", opts.storagePath, "Specify the path to the persistent storage location.")
fs.IntVar(&opts.storagePruneDays, "storage-prune-days", opts.storagePruneDays, "Define the number of days the storage prune should run using.")
fs.IntVar(&opts.storagePruneDays, "SPD", opts.storagePruneDays, "Define the number of days the storage prune should run using.")
fs.IntVar(&opts.storageUIDLength, "storage-uid-length", opts.storageUIDLength, "Define the number of unique characters to store persistent cache in.")
fs.IntVar(&opts.storageUIDLength, "SUL", opts.storageUIDLength, "Define the number of unique characters to store persistent cache in.")
fs.StringVar(&opts.storageMode, "storage-mode", opts.storageMode, "Specify the persistent storage operational mode.")
fs.StringVar(&opts.storageMode, "SM", opts.storageMode, "Specify the persistent storage operational mode.")
fs.BoolVar(&opts.showVersion, "version", false, "Display the apprise version and exit.")
fs.BoolVar(&opts.showVersion, "V", false, "Display the apprise version and exit.")
fs.BoolVar(&opts.showHelp, "help", false, "Show help.")
fs.BoolVar(&opts.showHelp, "h", false, "Show help.")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
Expand All @@ -56,41 +135,84 @@ func Run(args []string, stdout, stderr io.Writer) int {
return 2
}

if showHelp {
if opts.showHelp {
printUsage(stdout)
return 0
}

if showVersion {
if opts.showVersion {
fmt.Fprintln(stdout, version.Message())
return 0
}

nt, ok := notify.ParseNotifyType(notificationType)
if !ok {
fmt.Fprintf(stderr, "unsupported notification type: %s\n", notificationType)
return 2
if opts.showSchema {
schemaJSON, err := SchemaJSON()
if err != nil {
fmt.Fprintln(stderr, err)
return 1
}
if _, err := stdout.Write(append(schemaJSON, '\n')); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}

if opts.showDetails {
if err := PrintDetails(stdout); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}

urls := fs.Args()
if len(urls) == 0 {
printUsage(stdout)
if isStorageAction(urls) {
return RunStorage(&opts, urls, stdout, stderr)
}

tagged := resolveNotifyURLs(&opts, urls, stderr)
if len(tagged) == 0 {
fmt.Fprintln(stdout, "You must specify at least one server URL or populated configuration file.")
fmt.Fprintln(stdout, "Try 'apprise --help' for more information.")
return 1
}

nt, ok := notify.ParseNotifyType(opts.notificationType)
if !ok {
fmt.Fprintf(stderr, "unsupported notification type: %s\n", opts.notificationType)
return 2
}

body := opts.body
title := opts.title
if body == "" {
data, err := io.ReadAll(os.Stdin)
if err == nil {
body = string(data)
}
}

_ = inputFormat
_ = disableAsync
// TODO: Wire these options into CLI behavior once the runtime supports them.
_ = opts.inputFormat
_ = opts.disableAsync
_ = opts.attachments
_ = opts.pluginPaths
_ = opts.theme
_ = opts.recursionDepth
_ = opts.dryRun
_ = opts.debug
_ = opts.verbose
_ = opts.interpretEscapes
_ = opts.interpretEmojis
_ = opts.storageMode
_ = opts.storagePath
_ = opts.storagePruneDays
_ = opts.storageUIDLength

failed := false
for _, rawURL := range urls {
parsed, err := notify.ParseURL(rawURL)
for _, entry := range tagged {
parsed, err := notify.ParseURL(entry.URL)
if err != nil {
fmt.Fprintf(stderr, "invalid url: %s\n", err)
failed = true
Expand Down Expand Up @@ -1358,3 +1480,95 @@ func Run(args []string, stdout, stderr io.Writer) int {
func printUsage(w io.Writer) {
fmt.Fprint(w, usageText)
}

func defaultCliOptions() cliOptions {
return cliOptions{
notificationType: string(notify.NotifyInfo),
inputFormat: "text",
theme: "default",
recursionDepth: 1,
storagePath: defaultStoragePath,
storageMode: defaultStorageMode,
storagePruneDays: envInt("APPRISE_STORAGE_PRUNE_DAYS", defaultStoragePruneDays),
storageUIDLength: envInt("APPRISE_STORAGE_UID_LENGTH", defaultStorageUIDLength),
}
}

func envInt(name string, fallback int) int {
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
if value, err := strconv.Atoi(raw); err == nil {
return value
}
}
return fallback
}

func normalizeArgs(args []string) []string {
normalized := []string{}
for _, arg := range args {
if isVerboseBundle(arg) {
for range strings.TrimPrefix(arg, "-") {
normalized = append(normalized, "-v")
}
continue
}
normalized = append(normalized, arg)
}
return normalized
}

func isVerboseBundle(arg string) bool {
if len(arg) < 3 || !strings.HasPrefix(arg, "-") {
return false
}
trimmed := strings.TrimPrefix(arg, "-")
for _, r := range trimmed {
if r != 'v' {
return false
}
}
return true
}

func isStorageAction(args []string) bool {
if len(args) == 0 {
return false
}
return strings.HasPrefix("storage", strings.ToLower(args[0]))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func resolveNotifyURLs(opts *cliOptions, args []string, stderr io.Writer) []taggedURL {
if len(args) > 0 {
if len(opts.tags) > 0 {
fmt.Fprintln(stderr, "--tag (-g) entries are ignored when using specified URLs")
}
if len(opts.configPaths) > 0 {
fmt.Fprintln(stderr, "You defined both URLs and a --config (-c) entry; Only the URLs will be referenced.")
}

var urls []taggedURL
for _, arg := range args {
for _, raw := range detectURLs(arg) {
if strings.TrimSpace(raw) == "" {
continue
}
urls = append(urls, taggedURL{URL: raw})
}
}
return urls
}

if len(opts.configPaths) > 0 {
return filterTaggedURLs(loadTaggedURLs(loadConfigPaths(opts.configPaths)), parseTagFilters(opts.tags))
}

if raw := strings.TrimSpace(os.Getenv(defaultEnvAppriseURLs)); raw != "" {
parsed := parseTaggedLine(raw)
if len(parsed) == 0 {
return nil
}
return filterTaggedURLs(parsed, parseTagFilters(opts.tags))
}

return filterTaggedURLs(loadTaggedURLs(loadConfigPaths(nil)), parseTagFilters(opts.tags))
}
Loading