@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vforsh/notif",
3
- "version": "0.1.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"
@@ -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` | | Local file attachment |
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
- Replace with the machine's LAN IP before sending:
46
+ Use `lan_ip` from config (check with `notif cfg ls`) instead:
47
47
 
48
48
  ```bash
49
- LAN_IP=$(ipconfig getifaddr en0)
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 {
@@ -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 PUT (must use headers for metadata)
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 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}`);
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
- return (await resp.json()) as PublishResult;
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
+ }