@vforsh/notif 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # notif
2
+
3
+ CLI for sending push notifications via [ntfy](https://ntfy.sh). No ntfy binary needed — uses the REST API directly.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun install -g @vforsh/notif
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ notif cfg init
15
+ # or manually:
16
+ notif cfg set server https://ntfy.example.com
17
+ notif cfg set topic my-topic
18
+ ```
19
+
20
+ Verify setup:
21
+
22
+ ```bash
23
+ notif doctor
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # Simple message
30
+ notif "Hello world"
31
+
32
+ # With title and priority
33
+ notif -t "Alert" -p high "Backups failed"
34
+
35
+ # Tags / emojis
36
+ notif -T warning,skull "Oh no"
37
+
38
+ # Clickable notification
39
+ notif --click "https://example.com" "Check this out"
40
+
41
+ # File attachment
42
+ notif --file report.pdf "Daily report"
43
+
44
+ # Markdown
45
+ notif --md "**Build** passed in \`main\`"
46
+
47
+ # Pipe from stdin
48
+ echo "Deploy complete" | notif -t "CI"
49
+
50
+ # Override topic
51
+ notif --topic other-channel "Message"
52
+ ```
53
+
54
+ ## Config
55
+
56
+ Stored at `~/.config/notif/config.json`. Keys: `server`, `topic`, `user`, `token`.
57
+
58
+ ```bash
59
+ notif cfg ls # show path + contents
60
+ notif cfg init # interactive setup
61
+ notif cfg set <k> <v> # set value
62
+ notif cfg unset <k> # remove value
63
+ ```
64
+
65
+ Secrets (`user`, `token`) must be set via stdin:
66
+
67
+ ```bash
68
+ echo "tk_abc123" | notif cfg set token
69
+ ```
70
+
71
+ Precedence: CLI flags > env vars (`NOTIF_SERVER`, `NOTIF_TOPIC`, `NOTIF_USER`, `NOTIF_TOKEN`) > config file.
72
+
73
+ ## Auth
74
+
75
+ ```bash
76
+ # Access token
77
+ echo "tk_abc123" | notif cfg set token
78
+
79
+ # Username:password
80
+ echo "admin:secret" | notif cfg set user
81
+
82
+ # Or per-message
83
+ notif --token tk_abc123 "message"
84
+ ```
85
+
86
+ ## All flags
87
+
88
+ | Flag | Short | Description |
89
+ |------|-------|-------------|
90
+ | `--title` | `-t` | Message title |
91
+ | `--priority` | `-p` | Priority: 1=min, 2=low, 3=default, 4=high, 5=max |
92
+ | `--tags` | `-T` | Comma-separated tags/emojis |
93
+ | `--click` | | URL to open on notification click |
94
+ | `--icon` | | Notification icon URL |
95
+ | `--attach` | | External attachment URL |
96
+ | `--file` | | Local file to upload as attachment |
97
+ | `--filename` | | Override attachment filename |
98
+ | `--delay` | | Schedule message: `10s`, `30m`, ISO timestamp |
99
+ | `--email` | | Also send to email |
100
+ | `--actions` | | Actions JSON array or simple definition |
101
+ | `--md` | | Markdown formatting |
102
+ | `--no-cache` | | Don't cache server-side |
103
+ | `--no-firebase` | | Don't forward to Firebase |
104
+ | `--sid` | | Sequence ID for notification updates |
105
+ | `--topic` | | Override default topic |
106
+ | `--server` | | Override default server |
107
+ | `--user` | | `username:password` auth |
108
+ | `--token` | | Access token auth |
109
+ | `--json` | | Output raw JSON response |
110
+ | `--plain` | | Output only message ID |
111
+ | `-q` | | Suppress all output |
112
+
113
+ ## License
114
+
115
+ MIT
package/bin/notif ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "../src/index.ts";
3
+ await main(process.argv);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@vforsh/notif",
3
+ "version": "0.1.0",
4
+ "description": "CLI for sending push notifications via ntfy",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Vladislav Forsh <vforsh@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/vforsh/notif.git"
11
+ },
12
+ "bin": {
13
+ "notif": "bin/notif"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "skill"
19
+ ],
20
+ "scripts": {
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "test": "bun test",
23
+ "cmd": "bun run bin/notif"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "latest"
27
+ },
28
+ "peerDependencies": {
29
+ "typescript": "^5"
30
+ },
31
+ "dependencies": {
32
+ "commander": "^14.0.3",
33
+ "picocolors": "^1.1.1",
34
+ "zod": "^4.3.6"
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: notif
3
+ description: Send push notifications via ntfy using the `notif` CLI. Use when the agent needs to send notifications, alerts, or messages to a phone/desktop — task completion alerts, clickable links, file attachments, build status, deploy results, or any event worth notifying about. Triggers on mentions of notify, notification, push, alert, ntfy, notif, "send me", "let me know", "ping me".
4
+ ---
5
+
6
+ # notif
7
+
8
+ CLI for sending push notifications via ntfy. Server and topic are pre-configured — run `notif doctor` to verify setup.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ notif "Hello world"
14
+ notif -t "Alert" -p high "Backups failed"
15
+ notif -T warning,skull "Oh no"
16
+ notif --click "http://192.168.1.12:8080" "Server ready"
17
+ notif --file report.pdf "Daily report"
18
+ notif --md "**Build** passed in \`main\`"
19
+ notif --attach "https://example.com/image.png" "See this"
20
+ echo "Done" | notif -t "CI"
21
+ notif --topic other-channel "Override topic"
22
+ ```
23
+
24
+ ## Flags
25
+
26
+ | Flag | Short | Description |
27
+ |------|-------|-------------|
28
+ | `--title` | `-t` | Message title |
29
+ | `--priority` | `-p` | `1`=min, `2`=low, `3`=default, `4`=high, `5`=max |
30
+ | `--tags` | `-T` | Comma-separated tags/emojis |
31
+ | `--click` | | URL to open on tap |
32
+ | `--file` | | Local file attachment |
33
+ | `--attach` | | Remote URL attachment |
34
+ | `--md` | | Markdown formatting |
35
+ | `--delay` | | Schedule: `10s`, `30m`, ISO timestamp |
36
+ | `--topic` | | Override default topic |
37
+ | `--server` | | Override default server |
38
+ | `--json` | | JSON response to stdout |
39
+ | `--plain` | | Message ID only to stdout |
40
+ | `-q` | | Suppress output |
41
+
42
+ ## Localhost rule
43
+
44
+ **NEVER send `localhost` or `127.0.0.1` URLs in notifications.** Notifications arrive on mobile devices on the same Wi-Fi network — localhost is unreachable there.
45
+
46
+ Replace with the machine's LAN IP before sending:
47
+
48
+ ```bash
49
+ LAN_IP=$(ipconfig getifaddr en0)
50
+ notif --click "http://$LAN_IP:3000" "Dev server ready"
51
+ ```
52
+
53
+ This applies to `--click` URLs, URLs in message body, and any other link.
54
+
55
+ ## Config
56
+
57
+ - `notif cfg ls` — show config path and contents
58
+ - `notif cfg init` — interactive setup (TTY)
59
+ - `notif cfg set <key> <value>` — set value (`server`, `topic`; secrets via stdin)
60
+ - `notif cfg unset <key>` — remove value
61
+ - `notif doctor` — verify setup (config, server reachability)
62
+
63
+ ## Errors
64
+
65
+ | Message | Cause |
66
+ |---------|-------|
67
+ | `No server configured` | Run `notif cfg set server <url>` |
68
+ | `No topic configured` | Run `notif cfg set topic <name>` |
69
+ | `ntfy error 401` | Auth failed |
70
+ | `ntfy error 429` | Rate limited |
@@ -0,0 +1,139 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import {
4
+ getConfigPath,
5
+ readConfig,
6
+ writeConfig,
7
+ setConfigValue,
8
+ setConfigValueFromStdin,
9
+ unsetConfigValue,
10
+ type Config,
11
+ } from "../../lib/config.ts";
12
+
13
+ const SECRET_KEYS = new Set(["user", "token"]);
14
+
15
+ function redact(key: string, value: string): string {
16
+ return SECRET_KEYS.has(key) ? "*".repeat(Math.min(value.length, 12)) : value;
17
+ }
18
+
19
+ async function prompt(label: string, current?: string, secret = false): Promise<string | undefined> {
20
+ const suffix = current ? pc.dim(` [${secret ? "****" : current}]`) : "";
21
+ process.stderr.write(`${label}${suffix}: `);
22
+
23
+ for await (const chunk of Bun.stdin.stream()) {
24
+ const line = new TextDecoder().decode(chunk).trim();
25
+ return line || current;
26
+ }
27
+
28
+ return current;
29
+ }
30
+
31
+ export function registerConfigCommand(program: Command) {
32
+ const config = program
33
+ .command("config")
34
+ .alias("cfg")
35
+ .description("Manage notif configuration");
36
+
37
+ config
38
+ .command("ls")
39
+ .description("Print config path and contents")
40
+ .action(async () => {
41
+ const path = getConfigPath();
42
+ console.log(pc.dim(path));
43
+
44
+ const cfg = await readConfig();
45
+ if (Object.keys(cfg).length === 0) {
46
+ console.log(pc.dim("(empty)"));
47
+ return;
48
+ }
49
+
50
+ for (const [key, value] of Object.entries(cfg)) {
51
+ if (value !== undefined) {
52
+ console.log(`${key}: ${redact(key, value)}`);
53
+ }
54
+ }
55
+ });
56
+
57
+ config
58
+ .command("init")
59
+ .description("Interactively configure notif")
60
+ .action(async () => {
61
+ if (!process.stdin.isTTY) {
62
+ console.error(pc.red("cfg init requires a TTY"));
63
+ process.exit(1);
64
+ }
65
+
66
+ const current = await readConfig();
67
+ console.error(pc.bold("notif config") + pc.dim(" (Enter to keep current value, empty to clear)\n"));
68
+
69
+ const server = await prompt("server", current.server);
70
+ const topic = await prompt("topic", current.topic);
71
+ const user = await prompt("user", current.user, true);
72
+ const token = await prompt("token", current.token, true);
73
+
74
+ const updated: Config = {};
75
+ if (server) updated.server = server;
76
+ if (topic) updated.topic = topic;
77
+ if (user) updated.user = user;
78
+ if (token) updated.token = token;
79
+
80
+ await writeConfig(updated);
81
+ console.error(pc.green("\nSaved"), pc.dim(getConfigPath()));
82
+ });
83
+
84
+ config
85
+ .command("path")
86
+ .description("Print config file path")
87
+ .action(() => {
88
+ console.log(getConfigPath());
89
+ });
90
+
91
+ config
92
+ .command("get")
93
+ .description("Print current config values")
94
+ .action(async () => {
95
+ const cfg = await readConfig();
96
+ if (Object.keys(cfg).length === 0) {
97
+ console.error(pc.dim("(empty config)"));
98
+ return;
99
+ }
100
+
101
+ for (const [key, value] of Object.entries(cfg)) {
102
+ if (value !== undefined) {
103
+ console.log(`${key}: ${redact(key, value)}`);
104
+ }
105
+ }
106
+ });
107
+
108
+ config
109
+ .command("set")
110
+ .description("Set a config value")
111
+ .argument("<key>", "Config key (server, topic, user, token)")
112
+ .argument("[value]", "Value (omit for secrets — pipe via stdin)")
113
+ .action(async (key: string, value: string | undefined) => {
114
+ if (SECRET_KEYS.has(key)) {
115
+ if (value) {
116
+ console.error(pc.yellow(`Secrets must be set via stdin:`));
117
+ console.error(pc.dim(` echo "value" | notif cfg set ${key}`));
118
+ process.exit(1);
119
+ }
120
+ await setConfigValueFromStdin(key);
121
+ } else {
122
+ if (!value) {
123
+ console.error(pc.red(`Value required for ${key}`));
124
+ process.exit(1);
125
+ }
126
+ await setConfigValue(key, value);
127
+ }
128
+ console.error(pc.green(`Set ${key}`));
129
+ });
130
+
131
+ config
132
+ .command("unset")
133
+ .description("Remove a config value")
134
+ .argument("<key>", "Config key to remove")
135
+ .action(async (key: string) => {
136
+ await unsetConfigValue(key);
137
+ console.error(pc.green(`Unset ${key}`));
138
+ });
139
+ }
@@ -0,0 +1,136 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { getConfigPath, readConfig } from "../../lib/config.ts";
4
+
5
+ const PASS = pc.green("OK");
6
+ const FAIL = pc.red("FAIL");
7
+ const WARN = pc.yellow("WARN");
8
+
9
+ interface CheckResult {
10
+ status: "pass" | "fail" | "warn";
11
+ detail: string;
12
+ hint?: string;
13
+ }
14
+
15
+ interface Check {
16
+ name: string;
17
+ run: () => Promise<CheckResult>;
18
+ }
19
+
20
+ const checks: Check[] = [
21
+ {
22
+ name: "Config file",
23
+ run: async () => {
24
+ const path = getConfigPath();
25
+ const file = Bun.file(path);
26
+ if (!(await file.exists())) {
27
+ return { status: "fail", detail: `not found: ${path}`, hint: "notif cfg init" };
28
+ }
29
+
30
+ try {
31
+ await readConfig();
32
+ return { status: "pass", detail: path };
33
+ } catch (err) {
34
+ return {
35
+ status: "fail",
36
+ detail: err instanceof Error ? err.message : String(err),
37
+ hint: `Fix or delete ${path}, then run: notif cfg init`,
38
+ };
39
+ }
40
+ },
41
+ },
42
+ {
43
+ name: "Server",
44
+ run: async () => {
45
+ const cfg = await readConfig();
46
+ const server = process.env.NOTIF_SERVER || cfg.server;
47
+ if (!server) {
48
+ return { status: "fail", detail: "not configured", hint: "notif cfg set server https://ntfy.example.com" };
49
+ }
50
+ return { status: "pass", detail: server };
51
+ },
52
+ },
53
+ {
54
+ name: "Topic",
55
+ run: async () => {
56
+ const cfg = await readConfig();
57
+ const topic = process.env.NOTIF_TOPIC || cfg.topic;
58
+ if (!topic) {
59
+ return { status: "fail", detail: "not configured", hint: "notif cfg set topic my-topic" };
60
+ }
61
+ return { status: "pass", detail: topic };
62
+ },
63
+ },
64
+ {
65
+ name: "Auth",
66
+ run: async () => {
67
+ const cfg = await readConfig();
68
+ const token = process.env.NOTIF_TOKEN || cfg.token;
69
+ const user = process.env.NOTIF_USER || cfg.user;
70
+
71
+ if (token) return { status: "pass", detail: "token" };
72
+ if (user) {
73
+ if (!user.includes(":")) {
74
+ return {
75
+ status: "warn",
76
+ detail: "user format should be username:password",
77
+ hint: 'echo "user:pass" | notif cfg set user',
78
+ };
79
+ }
80
+ return { status: "pass", detail: "user:pass" };
81
+ }
82
+ return { status: "pass", detail: "none (anonymous)" };
83
+ },
84
+ },
85
+ {
86
+ name: "Server reachable",
87
+ run: async () => {
88
+ const cfg = await readConfig();
89
+ const server = process.env.NOTIF_SERVER || cfg.server;
90
+ if (!server) {
91
+ return { status: "fail", detail: "no server to check", hint: "set server first" };
92
+ }
93
+
94
+ const url = `${server.replace(/\/+$/, "")}/v1/health`;
95
+ try {
96
+ const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
97
+ if (!resp.ok) {
98
+ return { status: "fail", detail: `HTTP ${resp.status}`, hint: `check server URL: ${server}` };
99
+ }
100
+ const body = (await resp.json()) as { healthy?: boolean };
101
+ if (body.healthy) {
102
+ return { status: "pass", detail: "healthy" };
103
+ }
104
+ return { status: "warn", detail: "responded but not healthy", hint: "check ntfy server logs" };
105
+ } catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ return { status: "fail", detail: msg, hint: `verify server is running at ${server}` };
108
+ }
109
+ },
110
+ },
111
+ ];
112
+
113
+ export function registerDoctorCommand(program: Command) {
114
+ program
115
+ .command("doctor")
116
+ .alias("check")
117
+ .description("Verify notif setup")
118
+ .action(async () => {
119
+ let hasFailure = false;
120
+
121
+ for (const check of checks) {
122
+ const result = await check.run();
123
+ const icon = result.status === "pass" ? PASS : result.status === "warn" ? WARN : FAIL;
124
+ let line = `${icon} ${check.name}: ${pc.dim(result.detail)}`;
125
+ if (result.hint && result.status !== "pass") {
126
+ line += `\n ${pc.yellow(">")} ${result.hint}`;
127
+ }
128
+ console.log(line);
129
+ if (result.status === "fail") hasFailure = true;
130
+ }
131
+
132
+ if (hasFailure) {
133
+ process.exit(1);
134
+ }
135
+ });
136
+ }
@@ -0,0 +1,67 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { publish, type PublishOptions } from "../../lib/publish.ts";
4
+
5
+ export function registerPubCommand(program: Command) {
6
+ program
7
+ .argument("[message...]", "Message to send")
8
+ .option("-t, --title <title>", "Message title")
9
+ .option("-p, --priority <priority>", "Priority (1=min, 2=low, 3=default, 4=high, 5=max)")
10
+ .option("-T, --tags <tags>", "Comma-separated tags/emojis")
11
+ .option("--click <url>", "URL to open on notification click")
12
+ .option("--icon <url>", "Notification icon URL")
13
+ .option("--attach <url>", "URL to send as external attachment")
14
+ .option("--file <path>", "File to upload as attachment")
15
+ .option("--filename <name>", "Filename for the attachment")
16
+ .option("--delay <delay>", "Delay/schedule message (e.g. 10s, 30m, 2025-01-01T10:00:00)")
17
+ .option("--email <email>", "Also send to email address")
18
+ .option("--actions <actions>", "Actions JSON array or simple definition")
19
+ .option("--md", "Treat message as Markdown")
20
+ .option("--no-cache", "Do not cache message server-side")
21
+ .option("--no-firebase", "Do not forward to Firebase")
22
+ .option("--sid <id>", "Sequence ID for updating notifications")
23
+ .option("--topic <topic>", "Override default topic")
24
+ .option("--server <url>", "Override default server")
25
+ .option("--user <user:pass>", "Username:password for auth")
26
+ .option("--token <token>", "Access token for auth")
27
+ .action(async (messageParts: string[], opts: Record<string, unknown>) => {
28
+ let message = messageParts.join(" ") || undefined;
29
+
30
+ // Read from stdin if no message and not a TTY
31
+ if (!message && !process.stdin.isTTY) {
32
+ message = (await Bun.stdin.text()).trim() || undefined;
33
+ }
34
+
35
+ const publishOpts: PublishOptions = {
36
+ server: opts.server as string | undefined,
37
+ topic: opts.topic as string | undefined,
38
+ title: opts.title as string | undefined,
39
+ priority: opts.priority as string | undefined,
40
+ tags: opts.tags as string | undefined,
41
+ click: opts.click as string | undefined,
42
+ icon: opts.icon as string | undefined,
43
+ attach: opts.attach as string | undefined,
44
+ filename: opts.filename as string | undefined,
45
+ delay: opts.delay as string | undefined,
46
+ email: opts.email as string | undefined,
47
+ actions: opts.actions as string | undefined,
48
+ markdown: opts.md as boolean | undefined,
49
+ noCache: opts.cache === false,
50
+ noFirebase: opts.firebase === false,
51
+ sequenceId: opts.sid as string | undefined,
52
+ file: opts.file as string | undefined,
53
+ user: opts.user as string | undefined,
54
+ token: opts.token as string | undefined,
55
+ };
56
+
57
+ const result = await publish(message, publishOpts);
58
+
59
+ if (program.opts().json) {
60
+ console.log(JSON.stringify(result));
61
+ } else if (program.opts().plain) {
62
+ console.log(result.id);
63
+ } else if (!program.opts().quiet) {
64
+ console.error(pc.green("Sent"), pc.dim(result.id));
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,22 @@
1
+ import { Command } from "commander";
2
+ import { registerPubCommand } from "./commands/pub.ts";
3
+ import { registerConfigCommand } from "./commands/config.ts";
4
+ import { registerDoctorCommand } from "./commands/doctor.ts";
5
+
6
+ export function buildProgram(): Command {
7
+ const program = new Command();
8
+
9
+ program
10
+ .name("notif")
11
+ .version("0.1.0")
12
+ .description("Send push notifications via ntfy")
13
+ .option("--json", "Output raw JSON response")
14
+ .option("--plain", "Output only the message ID")
15
+ .option("-q, --quiet", "Suppress all output");
16
+
17
+ registerPubCommand(program);
18
+ registerConfigCommand(program);
19
+ registerDoctorCommand(program);
20
+
21
+ return program;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import pc from "picocolors";
2
+ import { buildProgram } from "./cli/program.ts";
3
+
4
+ export async function main(argv: string[]) {
5
+ const program = buildProgram();
6
+
7
+ try {
8
+ await program.parseAsync(argv);
9
+ } catch (err) {
10
+ const message = err instanceof Error ? err.message : String(err);
11
+
12
+ if (program.opts().json) {
13
+ console.log(JSON.stringify({ error: { message, exitCode: 1 } }));
14
+ } else {
15
+ console.error(pc.red(message));
16
+ }
17
+
18
+ process.exit(1);
19
+ }
20
+ }
@@ -0,0 +1,62 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { readConfig, writeConfig, isValidKey, type Config } from "./config.ts";
5
+
6
+ let originalXDG: string | undefined;
7
+ let tempDir: string;
8
+
9
+ beforeEach(async () => {
10
+ originalXDG = process.env.XDG_CONFIG_HOME;
11
+ tempDir = join(tmpdir(), `notif-test-${Date.now()}`);
12
+ process.env.XDG_CONFIG_HOME = tempDir;
13
+ });
14
+
15
+ afterEach(async () => {
16
+ if (originalXDG !== undefined) {
17
+ process.env.XDG_CONFIG_HOME = originalXDG;
18
+ } else {
19
+ delete process.env.XDG_CONFIG_HOME;
20
+ }
21
+ await Bun.$`rm -rf ${tempDir}`.quiet().nothrow();
22
+ });
23
+
24
+ test("readConfig returns empty object when no config file", async () => {
25
+ const config = await readConfig();
26
+ expect(config).toEqual({});
27
+ });
28
+
29
+ test("writeConfig + readConfig roundtrip", async () => {
30
+ const config: Config = {
31
+ server: "https://ntfy.example.com",
32
+ topic: "test-topic",
33
+ };
34
+ await writeConfig(config);
35
+ const read = await readConfig();
36
+ expect(read).toEqual(config);
37
+ });
38
+
39
+ test("writeConfig preserves all fields", async () => {
40
+ const config: Config = {
41
+ server: "https://ntfy.example.com",
42
+ topic: "test",
43
+ user: "admin:pass",
44
+ token: "tk_abc123",
45
+ };
46
+ await writeConfig(config);
47
+ const read = await readConfig();
48
+ expect(read).toEqual(config);
49
+ });
50
+
51
+ test("isValidKey accepts valid keys", () => {
52
+ expect(isValidKey("server")).toBe(true);
53
+ expect(isValidKey("topic")).toBe(true);
54
+ expect(isValidKey("user")).toBe(true);
55
+ expect(isValidKey("token")).toBe(true);
56
+ });
57
+
58
+ test("isValidKey rejects invalid keys", () => {
59
+ expect(isValidKey("foo")).toBe(false);
60
+ expect(isValidKey("")).toBe(false);
61
+ expect(isValidKey("SERVER")).toBe(false);
62
+ });
@@ -0,0 +1,93 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { z } from "zod/v4";
4
+
5
+ const CONFIG_DIR_NAME = "notif";
6
+ const CONFIG_FILE_NAME = "config.json";
7
+
8
+ const ConfigSchema = z.object({
9
+ server: z.url().optional(),
10
+ topic: z.string().optional(),
11
+ user: z.string().optional(),
12
+ token: z.string().optional(),
13
+ });
14
+
15
+ export type Config = z.infer<typeof ConfigSchema>;
16
+
17
+ const VALID_KEYS = ["server", "topic", "user", "token"] as const;
18
+ type ConfigKey = (typeof VALID_KEYS)[number];
19
+
20
+ export function isValidKey(key: string): key is ConfigKey {
21
+ return VALID_KEYS.includes(key as ConfigKey);
22
+ }
23
+
24
+ export function getConfigDir(): string {
25
+ const xdg = process.env.XDG_CONFIG_HOME;
26
+ const base = xdg || join(homedir(), ".config");
27
+ return join(base, CONFIG_DIR_NAME);
28
+ }
29
+
30
+ export function getConfigPath(): string {
31
+ return join(getConfigDir(), CONFIG_FILE_NAME);
32
+ }
33
+
34
+ export async function readConfig(): Promise<Config> {
35
+ const path = getConfigPath();
36
+ const file = Bun.file(path);
37
+ if (!(await file.exists())) return {};
38
+
39
+ const raw = await file.json();
40
+ const result = ConfigSchema.safeParse(raw);
41
+ if (!result.success) {
42
+ throw new Error(`Invalid config: ${z.prettifyError(result.error)}`);
43
+ }
44
+
45
+ return result.data;
46
+ }
47
+
48
+ export async function writeConfig(config: Config): Promise<void> {
49
+ const dir = getConfigDir();
50
+ await Bun.$`mkdir -p ${dir}`.quiet();
51
+
52
+ const path = getConfigPath();
53
+ await Bun.write(path, JSON.stringify(config, null, 2) + "\n");
54
+ }
55
+
56
+ export async function setConfigValue(key: string, value: string): Promise<void> {
57
+ if (!isValidKey(key)) {
58
+ throw new Error(`Unknown config key: ${key}. Valid keys: ${VALID_KEYS.join(", ")}`);
59
+ }
60
+
61
+ if (key === "user" || key === "token") {
62
+ throw new Error(`Secrets must be set via stdin: echo "value" | notif config set ${key}`);
63
+ }
64
+
65
+ const config = await readConfig();
66
+ config[key] = value;
67
+ await writeConfig(config);
68
+ }
69
+
70
+ export async function setConfigValueFromStdin(key: string): Promise<void> {
71
+ if (!isValidKey(key)) {
72
+ throw new Error(`Unknown config key: ${key}. Valid keys: ${VALID_KEYS.join(", ")}`);
73
+ }
74
+
75
+ const value = (await Bun.stdin.text()).trim();
76
+ if (!value) {
77
+ throw new Error("No value provided via stdin");
78
+ }
79
+
80
+ const config = await readConfig();
81
+ config[key] = value;
82
+ await writeConfig(config);
83
+ }
84
+
85
+ export async function unsetConfigValue(key: string): Promise<void> {
86
+ if (!isValidKey(key)) {
87
+ throw new Error(`Unknown config key: ${key}. Valid keys: ${VALID_KEYS.join(", ")}`);
88
+ }
89
+
90
+ const config = await readConfig();
91
+ delete config[key];
92
+ await writeConfig(config);
93
+ }
@@ -0,0 +1,163 @@
1
+ import { test, expect, beforeEach, afterEach, mock } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { writeConfig } from "./config.ts";
5
+
6
+ let originalXDG: string | undefined;
7
+ let tempDir: string;
8
+ let originalFetch: typeof globalThis.fetch;
9
+
10
+ function mockFetch(handler: (url: string, init: RequestInit) => void) {
11
+ const fn = mock(async (input: string | URL | Request, init?: RequestInit) => {
12
+ handler(String(input), init || {});
13
+ return new Response(JSON.stringify({ id: "abc123", time: 1, expires: 2, event: "message", topic: "test-topic" }), {
14
+ status: 200,
15
+ headers: { "Content-Type": "application/json" },
16
+ });
17
+ });
18
+ globalThis.fetch = Object.assign(fn, { preconnect: globalThis.fetch.preconnect }) as typeof fetch;
19
+ }
20
+
21
+ function mockFetchError(status: number, body: string) {
22
+ const fn = mock(async () => new Response(body, { status }));
23
+ globalThis.fetch = Object.assign(fn, { preconnect: globalThis.fetch.preconnect }) as typeof fetch;
24
+ }
25
+
26
+ beforeEach(async () => {
27
+ originalXDG = process.env.XDG_CONFIG_HOME;
28
+ tempDir = join(tmpdir(), `notif-test-${Date.now()}`);
29
+ process.env.XDG_CONFIG_HOME = tempDir;
30
+ originalFetch = globalThis.fetch;
31
+
32
+ await writeConfig({
33
+ server: "https://ntfy.test.local",
34
+ topic: "test-topic",
35
+ });
36
+ });
37
+
38
+ afterEach(async () => {
39
+ globalThis.fetch = originalFetch;
40
+ if (originalXDG !== undefined) {
41
+ process.env.XDG_CONFIG_HOME = originalXDG;
42
+ } else {
43
+ delete process.env.XDG_CONFIG_HOME;
44
+ }
45
+ delete process.env.NOTIF_SERVER;
46
+ delete process.env.NOTIF_TOPIC;
47
+ await Bun.$`rm -rf ${tempDir}`.quiet().nothrow();
48
+ });
49
+
50
+ test("publish sends JSON POST to server base URL", async () => {
51
+ let capturedUrl = "";
52
+ let capturedBody: Record<string, unknown> = {};
53
+
54
+ mockFetch((url, init) => {
55
+ capturedUrl = url;
56
+ capturedBody = JSON.parse(init.body as string);
57
+ });
58
+
59
+ const { publish } = await import("./publish.ts");
60
+ const result = await publish("hello world", {});
61
+
62
+ expect(capturedUrl).toBe("https://ntfy.test.local");
63
+ expect(capturedBody.topic).toBe("test-topic");
64
+ expect(capturedBody.message).toBe("hello world");
65
+ expect(result.id).toBe("abc123");
66
+ });
67
+
68
+ test("publish includes all options in JSON body", async () => {
69
+ let capturedBody: Record<string, unknown> = {};
70
+
71
+ mockFetch((_url, init) => {
72
+ capturedBody = JSON.parse(init.body as string);
73
+ });
74
+
75
+ const { publish } = await import("./publish.ts");
76
+ await publish("msg", {
77
+ title: "My Title",
78
+ priority: "high",
79
+ tags: "warning,skull",
80
+ click: "https://example.com",
81
+ markdown: true,
82
+ });
83
+
84
+ expect(capturedBody.title).toBe("My Title");
85
+ expect(capturedBody.priority).toBe("high");
86
+ expect(capturedBody.tags).toEqual(["warning", "skull"]);
87
+ expect(capturedBody.click).toBe("https://example.com");
88
+ expect(capturedBody.markdown).toBe(true);
89
+ });
90
+
91
+ test("publish uses Bearer auth when token is set", async () => {
92
+ let capturedHeaders: Record<string, string> = {};
93
+
94
+ mockFetch((_url, init) => {
95
+ capturedHeaders = Object.fromEntries(Object.entries(init.headers || {}));
96
+ });
97
+
98
+ const { publish } = await import("./publish.ts");
99
+ await publish("msg", { token: "tk_abc" });
100
+
101
+ expect(capturedHeaders["Authorization"]).toBe("Bearer tk_abc");
102
+ });
103
+
104
+ test("publish throws on missing server", async () => {
105
+ await writeConfig({});
106
+ const { publish } = await import("./publish.ts");
107
+ expect(publish("msg", {})).rejects.toThrow("No server configured");
108
+ });
109
+
110
+ test("publish throws on HTTP error", async () => {
111
+ mockFetchError(401, "Unauthorized");
112
+ const { publish } = await import("./publish.ts");
113
+ expect(publish("msg", {})).rejects.toThrow("ntfy error 401");
114
+ });
115
+
116
+ test("env vars override config", async () => {
117
+ let capturedBody: Record<string, unknown> = {};
118
+
119
+ process.env.NOTIF_SERVER = "https://env.server.com";
120
+ process.env.NOTIF_TOPIC = "env-topic";
121
+
122
+ mockFetch((_url, init) => {
123
+ capturedBody = JSON.parse(init.body as string);
124
+ });
125
+
126
+ const { publish } = await import("./publish.ts");
127
+ await publish("msg", {});
128
+
129
+ expect(capturedBody.topic).toBe("env-topic");
130
+ });
131
+
132
+ test("CLI flags override env and config", async () => {
133
+ let capturedUrl = "";
134
+ let capturedBody: Record<string, unknown> = {};
135
+
136
+ process.env.NOTIF_SERVER = "https://env.server.com";
137
+ process.env.NOTIF_TOPIC = "env-topic";
138
+
139
+ mockFetch((url, init) => {
140
+ capturedUrl = url;
141
+ capturedBody = JSON.parse(init.body as string);
142
+ });
143
+
144
+ const { publish } = await import("./publish.ts");
145
+ await publish("msg", { server: "https://flag.server.com", topic: "flag-topic" });
146
+
147
+ expect(capturedUrl).toBe("https://flag.server.com");
148
+ expect(capturedBody.topic).toBe("flag-topic");
149
+ });
150
+
151
+ test("publish handles UTF-8 title correctly", async () => {
152
+ let capturedBody: Record<string, unknown> = {};
153
+
154
+ mockFetch((_url, init) => {
155
+ capturedBody = JSON.parse(init.body as string);
156
+ });
157
+
158
+ const { publish } = await import("./publish.ts");
159
+ await publish("Привет мир", { title: "Тест — уведомление" });
160
+
161
+ expect(capturedBody.title).toBe("Тест — уведомление");
162
+ expect(capturedBody.message).toBe("Привет мир");
163
+ });
@@ -0,0 +1,129 @@
1
+ import { readConfig } from "./config.ts";
2
+
3
+ export interface PublishOptions {
4
+ server?: string;
5
+ topic?: string;
6
+ title?: string;
7
+ priority?: string;
8
+ tags?: string;
9
+ click?: string;
10
+ icon?: string;
11
+ attach?: string;
12
+ filename?: string;
13
+ delay?: string;
14
+ email?: string;
15
+ actions?: string;
16
+ markdown?: boolean;
17
+ noCache?: boolean;
18
+ noFirebase?: boolean;
19
+ sequenceId?: string;
20
+ file?: string;
21
+ user?: string;
22
+ token?: string;
23
+ }
24
+
25
+ export interface PublishResult {
26
+ id: string;
27
+ time: number;
28
+ expires: number;
29
+ event: string;
30
+ topic: string;
31
+ message?: string;
32
+ title?: string;
33
+ priority?: number;
34
+ tags?: string[];
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ export async function publish(message: string | undefined, opts: PublishOptions): Promise<PublishResult> {
39
+ const config = await readConfig();
40
+
41
+ const server = opts.server || process.env.NOTIF_SERVER || config.server;
42
+ const topic = opts.topic || process.env.NOTIF_TOPIC || config.topic;
43
+ const user = opts.user || process.env.NOTIF_USER || config.user;
44
+ const token = opts.token || process.env.NOTIF_TOKEN || config.token;
45
+
46
+ if (!server) {
47
+ throw new Error("No server configured. Run: notif config set server https://ntfy.example.com");
48
+ }
49
+ if (!topic) {
50
+ throw new Error("No topic configured. Run: notif config set topic my-topic");
51
+ }
52
+
53
+ const baseUrl = server.replace(/\/+$/, "");
54
+ const authHeaders: Record<string, string> = {};
55
+
56
+ if (token) {
57
+ authHeaders["Authorization"] = `Bearer ${token}`;
58
+ } else if (user) {
59
+ authHeaders["Authorization"] = `Basic ${btoa(user)}`;
60
+ }
61
+
62
+ // File upload via PUT (must use headers for metadata)
63
+ if (opts.file) {
64
+ const file = Bun.file(opts.file);
65
+ if (!(await file.exists())) {
66
+ throw new Error(`File not found: ${opts.file}`);
67
+ }
68
+
69
+ const headers: Record<string, string> = { ...authHeaders };
70
+ if (opts.title) headers["Title"] = opts.title;
71
+ if (opts.priority) headers["Priority"] = opts.priority;
72
+ if (opts.tags) headers["Tags"] = opts.tags;
73
+ if (opts.click) headers["Click"] = opts.click;
74
+ if (opts.icon) headers["Icon"] = opts.icon;
75
+ if (opts.delay) headers["Delay"] = opts.delay;
76
+ if (opts.email) headers["Email"] = opts.email;
77
+ if (opts.actions) headers["Actions"] = opts.actions;
78
+ if (opts.markdown) headers["Markdown"] = "yes";
79
+ if (opts.noCache) headers["Cache"] = "no";
80
+ if (opts.noFirebase) headers["Firebase"] = "no";
81
+ if (opts.sequenceId) headers["X-Sequence-ID"] = opts.sequenceId;
82
+ headers["Filename"] = opts.filename || opts.file.split("/").pop() || opts.file;
83
+ if (message) headers["Message"] = message;
84
+
85
+ const resp = await fetch(`${baseUrl}/${topic}`, {
86
+ method: "PUT",
87
+ headers,
88
+ body: file,
89
+ });
90
+
91
+ if (!resp.ok) {
92
+ const text = await resp.text();
93
+ throw new Error(`ntfy error ${resp.status}: ${text}`);
94
+ }
95
+
96
+ return (await resp.json()) as PublishResult;
97
+ }
98
+
99
+ // Regular message via JSON body (supports UTF-8 in all fields)
100
+ const body: Record<string, unknown> = { topic };
101
+ if (message) body.message = message;
102
+ if (opts.title) body.title = opts.title;
103
+ if (opts.priority) body.priority = isNaN(Number(opts.priority)) ? opts.priority : Number(opts.priority);
104
+ if (opts.tags) body.tags = opts.tags.split(",").map((t) => t.trim());
105
+ if (opts.click) body.click = opts.click;
106
+ if (opts.icon) body.icon = opts.icon;
107
+ if (opts.attach) body.attach = opts.attach;
108
+ if (opts.filename) body.filename = opts.filename;
109
+ if (opts.delay) body.delay = opts.delay;
110
+ if (opts.email) body.email = opts.email;
111
+ if (opts.actions) body.actions = opts.actions;
112
+ if (opts.markdown) body.markdown = true;
113
+ if (opts.noCache) body.cache = "no";
114
+ if (opts.noFirebase) body.firebase = "no";
115
+ if (opts.sequenceId) body["x-sequence-id"] = opts.sequenceId;
116
+
117
+ const resp = await fetch(baseUrl, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json", ...authHeaders },
120
+ body: JSON.stringify(body),
121
+ });
122
+
123
+ if (!resp.ok) {
124
+ const text = await resp.text();
125
+ throw new Error(`ntfy error ${resp.status}: ${text}`);
126
+ }
127
+
128
+ return (await resp.json()) as PublishResult;
129
+ }