@vforsh/notif 0.1.0 → 0.2.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 +14 -2
- package/src/cli/commands/config.ts +4 -0
- package/src/cli/commands/doctor.ts +25 -0
- package/src/lib/config.ts +3 -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.2.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 |
|
|
@@ -58,12 +58,24 @@ This applies to `--click` URLs, URLs in message body, and any other link.
|
|
|
58
58
|
- `notif cfg init` — interactive setup (TTY)
|
|
59
59
|
- `notif cfg set <key> <value>` — set value (`server`, `topic`; secrets via stdin)
|
|
60
60
|
- `notif cfg unset <key>` — remove value
|
|
61
|
-
- `notif doctor` — verify setup (config, server reachability)
|
|
61
|
+
- `notif doctor` — verify setup (config, server reachability, storage provider)
|
|
62
|
+
|
|
63
|
+
### Config keys
|
|
64
|
+
|
|
65
|
+
| Key | Description |
|
|
66
|
+
|-----|-------------|
|
|
67
|
+
| `server` | ntfy server URL |
|
|
68
|
+
| `topic` | Default topic |
|
|
69
|
+
| `user` | Auth user:pass (secret, set via stdin) |
|
|
70
|
+
| `token` | Auth token (secret, set via stdin) |
|
|
71
|
+
| `upload_provider` | Storage provider for `--file` uploads (e.g. `yadisk`) |
|
|
72
|
+
| `upload_path` | Remote directory for uploads (default: `/notif-uploads`) |
|
|
62
73
|
|
|
63
74
|
## Errors
|
|
64
75
|
|
|
65
76
|
| Message | Cause |
|
|
66
77
|
|---------|-------|
|
|
78
|
+
| `No storage provider configured` | Run `notif cfg set upload_provider yadisk` |
|
|
67
79
|
| `No server configured` | Run `notif cfg set server <url>` |
|
|
68
80
|
| `No topic configured` | Run `notif cfg set topic <name>` |
|
|
69
81
|
| `ntfy error 401` | Auth failed |
|
|
@@ -68,12 +68,16 @@ 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);
|
|
71
73
|
const user = await prompt("user", current.user, true);
|
|
72
74
|
const token = await prompt("token", current.token, true);
|
|
73
75
|
|
|
74
76
|
const updated: Config = {};
|
|
75
77
|
if (server) updated.server = server;
|
|
76
78
|
if (topic) updated.topic = topic;
|
|
79
|
+
if (upload_provider) updated.upload_provider = upload_provider;
|
|
80
|
+
if (upload_path) updated.upload_path = upload_path;
|
|
77
81
|
if (user) updated.user = user;
|
|
78
82
|
if (token) updated.token = token;
|
|
79
83
|
|
|
@@ -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,13 @@ 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(),
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
16
18
|
|
|
17
|
-
const VALID_KEYS = ["server", "topic", "user", "token"] as const;
|
|
19
|
+
const VALID_KEYS = ["server", "topic", "user", "token", "upload_provider", "upload_path"] as const;
|
|
18
20
|
type ConfigKey = (typeof VALID_KEYS)[number];
|
|
19
21
|
|
|
20
22
|
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
|
+
}
|