Skip to content

Commit 35dedcf

Browse files
authored
feat: cli add schema details and storage support (#24)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Revamped CLI: centralized options, repeatable flags, verbose expansion, immediate --help/--version, schema/details views, config loading and tag-filtered URL resolution * Storage CLI: list/prune/clear with dry-run and formatted output * Extensive provider schemas, schema-driven input parsing, overrides registry, and secure-scheme detection * Numerous notifier improvements: richer auth options, formats, templating and per-target behaviors * **Tests** * Expanded parity and unit tests for schemas, inputs, details, storage, and overrides * **Chores** * Workflow notes and .gitignore additions <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fed3607 commit 35dedcf

228 files changed

Lines changed: 31543 additions & 561 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@
1010

1111
# Local tooling
1212
/.venv/
13+
/.gocache/
14+
/.tmp/pycapture/
15+
/.tmp/pycases/
16+
__pycache__/

AGENTS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ Environment
1212
- CLI binary name: `apprise` (drop-in goal)
1313

1414
Workflow
15-
- Use Graphite (`gt`) for stacked PRs.
1615
- Tests should compare Go behavior to the installed Python apprise using a local capture server.
1716
- Request-spec parity is driven by provider folders in `internal/parity/providers/<provider>` with `manifest.json` and `cases.json`.
1817
- Keep Go version strings aligned with upstream apprise (see `internal/version/version.go`).

PROCESS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,14 @@ Test notes
105105
- Parity test runs fix time/nonce/JWT inputs via env defaults to keep AWS/OAuth/VAPID fixtures deterministic.
106106
- 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.
107107
- 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.
108+
- Python capture caching uses `.tmp/pycapture` by default; set `APPRISE_CAPTURE_CACHE=0` to disable or `APPRISE_CAPTURE_CACHE_DIR` to override.
109+
- Schema case caching uses `.tmp/pycases` by default; set `APPRISE_CASES_CACHE=0` to disable or `APPRISE_CASES_CACHE_DIR` to override.
108110
- Golden fixtures (`internal/parity/providers/<provider>/golden.json`) enable Python-free parity checks; regenerate with `internal/testutil/scripts/update_golden.py`.
109111
- Golden refresh example: `.venv/bin/python internal/testutil/scripts/update_golden.py`.
110112
- CI parity setup uses `scripts/ci/setup_parity_env.sh` and `scripts/ci/run_parity_tests.sh`.
113+
- Always run `go` commands with `GOCACHE=$PWD/.gocache` in this repo to avoid sandboxed cache permission errors.
111114
- Running `go test` in sandboxed environments may require `GOCACHE` set to a writable path and capture-server tests may need local listen permissions.
115+
- Parity subtests run in parallel by default; set `APPRISE_PARITY_SERIAL=1` to force serial execution.
112116

113117
Notes
114118
- Version updates: edit `internal/version/version.go` or override at build time with:

internal/cli/cli.go

Lines changed: 250 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"strconv"
910
"strings"
1011

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

21+
type cliOptions struct {
22+
body string
23+
title string
24+
notificationType string
25+
inputFormat string
26+
disableAsync bool
27+
showVersion bool
28+
showHelp bool
29+
showSchema bool
30+
showDetails bool
31+
dryRun bool
32+
debug bool
33+
verbose int
34+
recursionDepth int
35+
interpretEscapes bool
36+
interpretEmojis bool
37+
theme string
38+
configPaths []string
39+
attachments []string
40+
pluginPaths []string
41+
tags []string
42+
storagePath string
43+
storagePruneDays int
44+
storageUIDLength int
45+
storageMode string
46+
}
47+
48+
type stringSliceFlag []string
49+
50+
func (s *stringSliceFlag) String() string {
51+
return strings.Join(*s, ",")
52+
}
53+
54+
func (s *stringSliceFlag) Set(value string) error {
55+
*s = append(*s, value)
56+
return nil
57+
}
58+
59+
type countFlag int
60+
61+
func (c *countFlag) String() string {
62+
return strconv.Itoa(int(*c))
63+
}
64+
65+
func (c *countFlag) Set(value string) error {
66+
*c++
67+
return nil
68+
}
69+
70+
func (c *countFlag) IsBoolFlag() bool {
71+
return true
72+
}
73+
2074
func Run(args []string, stdout, stderr io.Writer) int {
75+
opts := defaultCliOptions()
76+
args = normalizeArgs(args)
2177
fs := flag.NewFlagSet("apprise", flag.ContinueOnError)
2278
fs.SetOutput(stderr)
2379

24-
var (
25-
body string
26-
title string
27-
notificationType string
28-
inputFormat string
29-
disableAsync bool
30-
showVersion bool
31-
showHelp bool
32-
)
33-
34-
fs.StringVar(&body, "body", "", "Specify the message body.")
35-
fs.StringVar(&body, "b", "", "Specify the message body.")
36-
fs.StringVar(&title, "title", "", "Specify the message title.")
37-
fs.StringVar(&title, "t", "", "Specify the message title.")
38-
fs.StringVar(&notificationType, "notification-type", string(notify.NotifyInfo), "Specify the message type.")
39-
fs.StringVar(&notificationType, "n", string(notify.NotifyInfo), "Specify the message type.")
40-
fs.StringVar(&inputFormat, "input-format", "text", "Specify the message input format.")
41-
fs.StringVar(&inputFormat, "i", "text", "Specify the message input format.")
42-
fs.BoolVar(&disableAsync, "disable-async", false, "Send all notifications sequentially.")
43-
fs.BoolVar(&disableAsync, "Da", false, "Send all notifications sequentially.")
44-
fs.BoolVar(&showVersion, "version", false, "Display the apprise version and exit.")
45-
fs.BoolVar(&showVersion, "V", false, "Display the apprise version and exit.")
46-
fs.BoolVar(&showHelp, "help", false, "Show help.")
47-
fs.BoolVar(&showHelp, "h", false, "Show help.")
80+
fs.StringVar(&opts.body, "body", "", "Specify the message body.")
81+
fs.StringVar(&opts.body, "b", "", "Specify the message body.")
82+
fs.StringVar(&opts.title, "title", "", "Specify the message title.")
83+
fs.StringVar(&opts.title, "t", "", "Specify the message title.")
84+
fs.StringVar(&opts.notificationType, "notification-type", opts.notificationType, "Specify the message type.")
85+
fs.StringVar(&opts.notificationType, "n", opts.notificationType, "Specify the message type.")
86+
fs.StringVar(&opts.inputFormat, "input-format", opts.inputFormat, "Specify the message input format.")
87+
fs.StringVar(&opts.inputFormat, "i", opts.inputFormat, "Specify the message input format.")
88+
fs.BoolVar(&opts.disableAsync, "disable-async", false, "Send all notifications sequentially.")
89+
fs.BoolVar(&opts.disableAsync, "Da", false, "Send all notifications sequentially.")
90+
fs.BoolVar(&opts.dryRun, "dry-run", false, "Perform a trial run without sending notifications.")
91+
fs.BoolVar(&opts.dryRun, "d", false, "Perform a trial run without sending notifications.")
92+
fs.BoolVar(&opts.showDetails, "details", false, "Prints details about the current services supported by Apprise.")
93+
fs.BoolVar(&opts.showDetails, "l", false, "Prints details about the current services supported by Apprise.")
94+
fs.BoolVar(&opts.showSchema, "schema", false, "Prints Apprise schema JSON and exits.")
95+
fs.IntVar(&opts.recursionDepth, "recursion-depth", opts.recursionDepth, "Specify the recursion depth when loading configs.")
96+
fs.IntVar(&opts.recursionDepth, "R", opts.recursionDepth, "Specify the recursion depth when loading configs.")
97+
fs.Var((*countFlag)(&opts.verbose), "v", "Increase verbosity.")
98+
fs.Var((*countFlag)(&opts.verbose), "verbose", "Increase verbosity.")
99+
fs.BoolVar(&opts.interpretEscapes, "interpret-escapes", false, "Enable interpretation of backslash escapes.")
100+
fs.BoolVar(&opts.interpretEscapes, "e", false, "Enable interpretation of backslash escapes.")
101+
fs.BoolVar(&opts.interpretEmojis, "interpret-emojis", false, "Enable interpretation of :emoji: definitions.")
102+
fs.BoolVar(&opts.interpretEmojis, "j", false, "Enable interpretation of :emoji: definitions.")
103+
fs.BoolVar(&opts.debug, "debug", false, "Debug mode.")
104+
fs.BoolVar(&opts.debug, "D", false, "Debug mode.")
105+
fs.StringVar(&opts.theme, "theme", opts.theme, "Specify the default theme.")
106+
fs.StringVar(&opts.theme, "T", opts.theme, "Specify the default theme.")
107+
fs.Var((*stringSliceFlag)(&opts.tags), "tag", "Specify tags used to filter which services to notify.")
108+
fs.Var((*stringSliceFlag)(&opts.tags), "g", "Specify tags used to filter which services to notify.")
109+
fs.Var((*stringSliceFlag)(&opts.configPaths), "config", "Specify one or more configuration locations.")
110+
fs.Var((*stringSliceFlag)(&opts.configPaths), "c", "Specify one or more configuration locations.")
111+
fs.Var((*stringSliceFlag)(&opts.attachments), "attach", "Specify one or more attachments.")
112+
fs.Var((*stringSliceFlag)(&opts.attachments), "a", "Specify one or more attachments.")
113+
fs.Var((*stringSliceFlag)(&opts.pluginPaths), "plugin-path", "Specify one or more plugin paths to scan.")
114+
fs.Var((*stringSliceFlag)(&opts.pluginPaths), "P", "Specify one or more plugin paths to scan.")
115+
fs.StringVar(&opts.storagePath, "storage-path", opts.storagePath, "Specify the path to the persistent storage location.")
116+
fs.StringVar(&opts.storagePath, "S", opts.storagePath, "Specify the path to the persistent storage location.")
117+
fs.IntVar(&opts.storagePruneDays, "storage-prune-days", opts.storagePruneDays, "Define the number of days the storage prune should run using.")
118+
fs.IntVar(&opts.storagePruneDays, "SPD", opts.storagePruneDays, "Define the number of days the storage prune should run using.")
119+
fs.IntVar(&opts.storageUIDLength, "storage-uid-length", opts.storageUIDLength, "Define the number of unique characters to store persistent cache in.")
120+
fs.IntVar(&opts.storageUIDLength, "SUL", opts.storageUIDLength, "Define the number of unique characters to store persistent cache in.")
121+
fs.StringVar(&opts.storageMode, "storage-mode", opts.storageMode, "Specify the persistent storage operational mode.")
122+
fs.StringVar(&opts.storageMode, "SM", opts.storageMode, "Specify the persistent storage operational mode.")
123+
fs.BoolVar(&opts.showVersion, "version", false, "Display the apprise version and exit.")
124+
fs.BoolVar(&opts.showVersion, "V", false, "Display the apprise version and exit.")
125+
fs.BoolVar(&opts.showHelp, "help", false, "Show help.")
126+
fs.BoolVar(&opts.showHelp, "h", false, "Show help.")
48127

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

59-
if showHelp {
138+
if opts.showHelp {
60139
printUsage(stdout)
61140
return 0
62141
}
63142

64-
if showVersion {
143+
if opts.showVersion {
65144
fmt.Fprintln(stdout, version.Message())
66145
return 0
67146
}
68147

69-
nt, ok := notify.ParseNotifyType(notificationType)
70-
if !ok {
71-
fmt.Fprintf(stderr, "unsupported notification type: %s\n", notificationType)
72-
return 2
148+
if opts.showSchema {
149+
schemaJSON, err := SchemaJSON()
150+
if err != nil {
151+
fmt.Fprintln(stderr, err)
152+
return 1
153+
}
154+
if _, err := stdout.Write(append(schemaJSON, '\n')); err != nil {
155+
fmt.Fprintln(stderr, err)
156+
return 1
157+
}
158+
return 0
159+
}
160+
161+
if opts.showDetails {
162+
if err := PrintDetails(stdout); err != nil {
163+
fmt.Fprintln(stderr, err)
164+
return 1
165+
}
166+
return 0
73167
}
74168

75169
urls := fs.Args()
76-
if len(urls) == 0 {
77-
printUsage(stdout)
170+
if isStorageAction(urls) {
171+
return RunStorage(&opts, urls, stdout, stderr)
172+
}
173+
174+
tagged := resolveNotifyURLs(&opts, urls, stderr)
175+
if len(tagged) == 0 {
176+
fmt.Fprintln(stdout, "You must specify at least one server URL or populated configuration file.")
177+
fmt.Fprintln(stdout, "Try 'apprise --help' for more information.")
78178
return 1
79179
}
80180

181+
nt, ok := notify.ParseNotifyType(opts.notificationType)
182+
if !ok {
183+
fmt.Fprintf(stderr, "unsupported notification type: %s\n", opts.notificationType)
184+
return 2
185+
}
186+
187+
body := opts.body
188+
title := opts.title
81189
if body == "" {
82190
data, err := io.ReadAll(os.Stdin)
83191
if err == nil {
84192
body = string(data)
85193
}
86194
}
87195

88-
_ = inputFormat
89-
_ = disableAsync
196+
// TODO: Wire these options into CLI behavior once the runtime supports them.
197+
_ = opts.inputFormat
198+
_ = opts.disableAsync
199+
_ = opts.attachments
200+
_ = opts.pluginPaths
201+
_ = opts.theme
202+
_ = opts.recursionDepth
203+
_ = opts.dryRun
204+
_ = opts.debug
205+
_ = opts.verbose
206+
_ = opts.interpretEscapes
207+
_ = opts.interpretEmojis
208+
_ = opts.storageMode
209+
_ = opts.storagePath
210+
_ = opts.storagePruneDays
211+
_ = opts.storageUIDLength
90212

91213
failed := false
92-
for _, rawURL := range urls {
93-
parsed, err := notify.ParseURL(rawURL)
214+
for _, entry := range tagged {
215+
parsed, err := notify.ParseURL(entry.URL)
94216
if err != nil {
95217
fmt.Fprintf(stderr, "invalid url: %s\n", err)
96218
failed = true
@@ -1358,3 +1480,95 @@ func Run(args []string, stdout, stderr io.Writer) int {
13581480
func printUsage(w io.Writer) {
13591481
fmt.Fprint(w, usageText)
13601482
}
1483+
1484+
func defaultCliOptions() cliOptions {
1485+
return cliOptions{
1486+
notificationType: string(notify.NotifyInfo),
1487+
inputFormat: "text",
1488+
theme: "default",
1489+
recursionDepth: 1,
1490+
storagePath: defaultStoragePath,
1491+
storageMode: defaultStorageMode,
1492+
storagePruneDays: envInt("APPRISE_STORAGE_PRUNE_DAYS", defaultStoragePruneDays),
1493+
storageUIDLength: envInt("APPRISE_STORAGE_UID_LENGTH", defaultStorageUIDLength),
1494+
}
1495+
}
1496+
1497+
func envInt(name string, fallback int) int {
1498+
if raw := strings.TrimSpace(os.Getenv(name)); raw != "" {
1499+
if value, err := strconv.Atoi(raw); err == nil {
1500+
return value
1501+
}
1502+
}
1503+
return fallback
1504+
}
1505+
1506+
func normalizeArgs(args []string) []string {
1507+
normalized := []string{}
1508+
for _, arg := range args {
1509+
if isVerboseBundle(arg) {
1510+
for range strings.TrimPrefix(arg, "-") {
1511+
normalized = append(normalized, "-v")
1512+
}
1513+
continue
1514+
}
1515+
normalized = append(normalized, arg)
1516+
}
1517+
return normalized
1518+
}
1519+
1520+
func isVerboseBundle(arg string) bool {
1521+
if len(arg) < 3 || !strings.HasPrefix(arg, "-") {
1522+
return false
1523+
}
1524+
trimmed := strings.TrimPrefix(arg, "-")
1525+
for _, r := range trimmed {
1526+
if r != 'v' {
1527+
return false
1528+
}
1529+
}
1530+
return true
1531+
}
1532+
1533+
func isStorageAction(args []string) bool {
1534+
if len(args) == 0 {
1535+
return false
1536+
}
1537+
return strings.HasPrefix("storage", strings.ToLower(args[0]))
1538+
}
1539+
1540+
func resolveNotifyURLs(opts *cliOptions, args []string, stderr io.Writer) []taggedURL {
1541+
if len(args) > 0 {
1542+
if len(opts.tags) > 0 {
1543+
fmt.Fprintln(stderr, "--tag (-g) entries are ignored when using specified URLs")
1544+
}
1545+
if len(opts.configPaths) > 0 {
1546+
fmt.Fprintln(stderr, "You defined both URLs and a --config (-c) entry; Only the URLs will be referenced.")
1547+
}
1548+
1549+
var urls []taggedURL
1550+
for _, arg := range args {
1551+
for _, raw := range detectURLs(arg) {
1552+
if strings.TrimSpace(raw) == "" {
1553+
continue
1554+
}
1555+
urls = append(urls, taggedURL{URL: raw})
1556+
}
1557+
}
1558+
return urls
1559+
}
1560+
1561+
if len(opts.configPaths) > 0 {
1562+
return filterTaggedURLs(loadTaggedURLs(loadConfigPaths(opts.configPaths)), parseTagFilters(opts.tags))
1563+
}
1564+
1565+
if raw := strings.TrimSpace(os.Getenv(defaultEnvAppriseURLs)); raw != "" {
1566+
parsed := parseTaggedLine(raw)
1567+
if len(parsed) == 0 {
1568+
return nil
1569+
}
1570+
return filterTaggedURLs(parsed, parseTagFilters(opts.tags))
1571+
}
1572+
1573+
return filterTaggedURLs(loadTaggedURLs(loadConfigPaths(nil)), parseTagFilters(opts.tags))
1574+
}

0 commit comments

Comments
 (0)