@vforsh/notif 0.1.0 → 0.3.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/package.json +2 -1
- package/skill/notif/SKILL.md +19 -5
- package/src/cli/commands/config.ts +6 -0
- package/src/cli/commands/doctor.ts +25 -0
- package/src/lib/config.ts +4 -1
- package/src/lib/publish.ts +13 -27
- package/src/lib/storage.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vforsh/notif",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI for sending push notifications via ntfy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"typescript": "^5"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@vforsh/yadisk": "^1.1.0",
|
|
32
33
|
"commander": "^14.0.3",
|
|
33
34
|
"picocolors": "^1.1.1",
|
|
34
35
|
"zod": "^4.3.6"
|
package/skill/notif/SKILL.md
CHANGED
|
@@ -29,7 +29,7 @@ notif --topic other-channel "Override topic"
|
|
|
29
29
|
| `--priority` | `-p` | `1`=min, `2`=low, `3`=default, `4`=high, `5`=max |
|
|
30
30
|
| `--tags` | `-T` | Comma-separated tags/emojis |
|
|
31
31
|
| `--click` | | URL to open on tap |
|
|
32
|
-
| `--file` | |
|
|
32
|
+
| `--file` | | Upload local file to storage provider, attach public URL |
|
|
33
33
|
| `--attach` | | Remote URL attachment |
|
|
34
34
|
| `--md` | | Markdown formatting |
|
|
35
35
|
| `--delay` | | Schedule: `10s`, `30m`, ISO timestamp |
|
|
@@ -43,13 +43,14 @@ notif --topic other-channel "Override topic"
|
|
|
43
43
|
|
|
44
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
45
|
|
|
46
|
-
|
|
46
|
+
Use `lan_ip` from config (check with `notif cfg ls`) instead:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
|
|
50
|
-
notif --click "http://$LAN_IP:3000" "Dev server ready"
|
|
49
|
+
notif --click "http://192.168.1.12:3000" "Dev server ready"
|
|
51
50
|
```
|
|
52
51
|
|
|
52
|
+
Fallback if `lan_ip` is not configured: `ipconfig getifaddr en0`.
|
|
53
|
+
|
|
53
54
|
This applies to `--click` URLs, URLs in message body, and any other link.
|
|
54
55
|
|
|
55
56
|
## Config
|
|
@@ -58,12 +59,25 @@ This applies to `--click` URLs, URLs in message body, and any other link.
|
|
|
58
59
|
- `notif cfg init` — interactive setup (TTY)
|
|
59
60
|
- `notif cfg set <key> <value>` — set value (`server`, `topic`; secrets via stdin)
|
|
60
61
|
- `notif cfg unset <key>` — remove value
|
|
61
|
-
- `notif doctor` — verify setup (config, server reachability)
|
|
62
|
+
- `notif doctor` — verify setup (config, server reachability, storage provider)
|
|
63
|
+
|
|
64
|
+
### Config keys
|
|
65
|
+
|
|
66
|
+
| Key | Description |
|
|
67
|
+
|-----|-------------|
|
|
68
|
+
| `server` | ntfy server URL |
|
|
69
|
+
| `topic` | Default topic |
|
|
70
|
+
| `user` | Auth user:pass (secret, set via stdin) |
|
|
71
|
+
| `token` | Auth token (secret, set via stdin) |
|
|
72
|
+
| `upload_provider` | Storage provider for `--file` uploads (e.g. `yadisk`) |
|
|
73
|
+
| `upload_path` | Remote directory for uploads (default: `/notif-uploads`) |
|
|
74
|
+
| `lan_ip` | Machine's LAN IP — use instead of `localhost` in notification URLs |
|
|
62
75
|
|
|
63
76
|
## Errors
|
|
64
77
|
|
|
65
78
|
| Message | Cause |
|
|
66
79
|
|---------|-------|
|
|
80
|
+
| `No storage provider configured` | Run `notif cfg set upload_provider yadisk` |
|
|
67
81
|
| `No server configured` | Run `notif cfg set server <url>` |
|
|
68
82
|
| `No topic configured` | Run `notif cfg set topic <name>` |
|
|
69
83
|
| `ntfy error 401` | Auth failed |
|
|
@@ -68,12 +68,18 @@ export function registerConfigCommand(program: Command) {
|
|
|
68
68
|
|
|
69
69
|
const server = await prompt("server", current.server);
|
|
70
70
|
const topic = await prompt("topic", current.topic);
|
|
71
|
+
const upload_provider = await prompt("upload_provider", current.upload_provider);
|
|
72
|
+
const upload_path = await prompt("upload_path", current.upload_path);
|
|
73
|
+
const lan_ip = await prompt("lan_ip", current.lan_ip);
|
|
71
74
|
const user = await prompt("user", current.user, true);
|
|
72
75
|
const token = await prompt("token", current.token, true);
|
|
73
76
|
|
|
74
77
|
const updated: Config = {};
|
|
75
78
|
if (server) updated.server = server;
|
|
76
79
|
if (topic) updated.topic = topic;
|
|
80
|
+
if (upload_provider) updated.upload_provider = upload_provider;
|
|
81
|
+
if (upload_path) updated.upload_path = upload_path;
|
|
82
|
+
if (lan_ip) updated.lan_ip = lan_ip;
|
|
77
83
|
if (user) updated.user = user;
|
|
78
84
|
if (token) updated.token = token;
|
|
79
85
|
|
|
@@ -82,6 +82,31 @@ const checks: Check[] = [
|
|
|
82
82
|
return { status: "pass", detail: "none (anonymous)" };
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: "Storage provider",
|
|
87
|
+
run: async () => {
|
|
88
|
+
const cfg = await readConfig();
|
|
89
|
+
const provider = process.env.NOTIF_UPLOAD_PROVIDER || cfg.upload_provider;
|
|
90
|
+
if (!provider) {
|
|
91
|
+
return {
|
|
92
|
+
status: "warn",
|
|
93
|
+
detail: "not configured (file uploads disabled)",
|
|
94
|
+
hint: "notif cfg set upload_provider yadisk",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { KNOWN_PROVIDERS } = await import("../../lib/storage.ts");
|
|
99
|
+
if (!KNOWN_PROVIDERS.includes(provider)) {
|
|
100
|
+
return {
|
|
101
|
+
status: "fail",
|
|
102
|
+
detail: `unknown provider: ${provider}`,
|
|
103
|
+
hint: `known providers: ${KNOWN_PROVIDERS.join(", ")}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { status: "pass", detail: provider };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
85
110
|
{
|
|
86
111
|
name: "Server reachable",
|
|
87
112
|
run: async () => {
|
package/src/lib/config.ts
CHANGED
|
@@ -10,11 +10,14 @@ const ConfigSchema = z.object({
|
|
|
10
10
|
topic: z.string().optional(),
|
|
11
11
|
user: z.string().optional(),
|
|
12
12
|
token: z.string().optional(),
|
|
13
|
+
upload_provider: z.string().optional(),
|
|
14
|
+
upload_path: z.string().optional(),
|
|
15
|
+
lan_ip: z.string().optional(),
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
16
19
|
|
|
17
|
-
const VALID_KEYS = ["server", "topic", "user", "token"] as const;
|
|
20
|
+
const VALID_KEYS = ["server", "topic", "user", "token", "upload_provider", "upload_path", "lan_ip"] as const;
|
|
18
21
|
type ConfigKey = (typeof VALID_KEYS)[number];
|
|
19
22
|
|
|
20
23
|
export function isValidKey(key: string): key is ConfigKey {
|
package/src/lib/publish.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readConfig } from "./config.ts";
|
|
2
|
+
import { getStorageProvider, buildRemotePath } from "./storage.ts";
|
|
2
3
|
|
|
3
4
|
export interface PublishOptions {
|
|
4
5
|
server?: string;
|
|
@@ -18,6 +19,8 @@ export interface PublishOptions {
|
|
|
18
19
|
noFirebase?: boolean;
|
|
19
20
|
sequenceId?: string;
|
|
20
21
|
file?: string;
|
|
22
|
+
uploadProvider?: string;
|
|
23
|
+
uploadPath?: string;
|
|
21
24
|
user?: string;
|
|
22
25
|
token?: string;
|
|
23
26
|
}
|
|
@@ -59,41 +62,24 @@ export async function publish(message: string | undefined, opts: PublishOptions)
|
|
|
59
62
|
authHeaders["Authorization"] = `Basic ${btoa(user)}`;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
// File upload via
|
|
65
|
+
// File upload via external storage provider → attach URL
|
|
63
66
|
if (opts.file) {
|
|
64
67
|
const file = Bun.file(opts.file);
|
|
65
68
|
if (!(await file.exists())) {
|
|
66
69
|
throw new Error(`File not found: ${opts.file}`);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
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}`);
|
|
72
|
+
const uploadProvider = opts.uploadProvider || process.env.NOTIF_UPLOAD_PROVIDER || config.upload_provider;
|
|
73
|
+
if (!uploadProvider) {
|
|
74
|
+
throw new Error("No storage provider configured. Run: notif config set upload_provider yadisk");
|
|
94
75
|
}
|
|
95
76
|
|
|
96
|
-
|
|
77
|
+
const uploadPath = opts.uploadPath || process.env.NOTIF_UPLOAD_PATH || config.upload_path || "/uploads/notif-cli";
|
|
78
|
+
const remotePath = buildRemotePath(uploadPath, opts.file, opts.filename);
|
|
79
|
+
const provider = getStorageProvider(uploadProvider);
|
|
80
|
+
const publicUrl = await provider.upload(opts.file, remotePath);
|
|
81
|
+
|
|
82
|
+
if (!opts.click) opts.click = publicUrl;
|
|
97
83
|
}
|
|
98
84
|
|
|
99
85
|
// Regular message via JSON body (supports UTF-8 in all fields)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface StorageProvider {
|
|
4
|
+
name: string;
|
|
5
|
+
upload(localPath: string, remotePath: string): Promise<string>; // returns public URL
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class YaDiskStorageProvider implements StorageProvider {
|
|
9
|
+
name = "yadisk";
|
|
10
|
+
|
|
11
|
+
async upload(localPath: string, remotePath: string): Promise<string> {
|
|
12
|
+
const pkg = "@vforsh/yadisk";
|
|
13
|
+
const { YaDiskClient, getCredentials } = await import(/* @vite-ignore */ pkg);
|
|
14
|
+
const credentials = getCredentials();
|
|
15
|
+
const client = new YaDiskClient(credentials);
|
|
16
|
+
|
|
17
|
+
const parentDir = remotePath.replace(/\/[^/]+$/, "");
|
|
18
|
+
if (parentDir) {
|
|
19
|
+
await client.mkdir(parentDir).catch(() => {}); // already exists is fine
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await client.upload(remotePath, localPath);
|
|
23
|
+
const url = await client.publish(remotePath);
|
|
24
|
+
if (!url) {
|
|
25
|
+
const existing = await client.getPublicUrl(remotePath);
|
|
26
|
+
if (!existing) throw new Error(`Failed to get public URL for ${remotePath}`);
|
|
27
|
+
return existing;
|
|
28
|
+
}
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PROVIDERS: Record<string, () => StorageProvider> = {
|
|
34
|
+
yadisk: () => new YaDiskStorageProvider(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const KNOWN_PROVIDERS = Object.keys(PROVIDERS);
|
|
38
|
+
|
|
39
|
+
export function getStorageProvider(name: string): StorageProvider {
|
|
40
|
+
const factory = PROVIDERS[name];
|
|
41
|
+
if (!factory) {
|
|
42
|
+
throw new Error(`Unknown storage provider: ${name}. Known: ${KNOWN_PROVIDERS.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
return factory();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildRemotePath(uploadDir: string, localPath: string, filenameOverride?: string): string {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const ts = [
|
|
50
|
+
now.getFullYear(),
|
|
51
|
+
String(now.getMonth() + 1).padStart(2, "0"),
|
|
52
|
+
String(now.getDate()).padStart(2, "0"),
|
|
53
|
+
"-",
|
|
54
|
+
String(now.getHours()).padStart(2, "0"),
|
|
55
|
+
String(now.getMinutes()).padStart(2, "0"),
|
|
56
|
+
String(now.getSeconds()).padStart(2, "0"),
|
|
57
|
+
].join("");
|
|
58
|
+
const filename = filenameOverride || basename(localPath);
|
|
59
|
+
return `${uploadDir.replace(/\/+$/, "")}/${ts}-${filename}`;
|
|
60
|
+
}
|