@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 +115 -0
- package/bin/notif +3 -0
- package/package.json +36 -0
- package/skill/notif/SKILL.md +70 -0
- package/src/cli/commands/config.ts +139 -0
- package/src/cli/commands/doctor.ts +136 -0
- package/src/cli/commands/pub.ts +67 -0
- package/src/cli/program.ts +22 -0
- package/src/index.ts +20 -0
- package/src/lib/config.test.ts +62 -0
- package/src/lib/config.ts +93 -0
- package/src/lib/publish.test.ts +163 -0
- package/src/lib/publish.ts +129 -0
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
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
|
+
}
|