@fermindi/pwn-cli 0.1.1 → 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/LICENSE +21 -21
- package/README.md +265 -251
- package/cli/batch.js +333 -333
- package/cli/codespaces.js +303 -303
- package/cli/index.js +98 -91
- package/cli/inject.js +78 -67
- package/cli/knowledge.js +531 -531
- package/cli/migrate.js +466 -0
- package/cli/notify.js +135 -135
- package/cli/patterns.js +665 -665
- package/cli/status.js +91 -91
- package/cli/validate.js +61 -61
- package/package.json +70 -70
- package/src/core/inject.js +208 -204
- package/src/core/state.js +91 -91
- package/src/core/validate.js +202 -202
- package/src/core/workspace.js +176 -176
- package/src/index.js +20 -20
- package/src/knowledge/gc.js +308 -308
- package/src/knowledge/lifecycle.js +401 -401
- package/src/knowledge/promote.js +364 -364
- package/src/knowledge/references.js +342 -342
- package/src/patterns/matcher.js +218 -218
- package/src/patterns/registry.js +375 -375
- package/src/patterns/triggers.js +423 -423
- package/src/services/batch-service.js +849 -849
- package/src/services/notification-service.js +342 -342
- package/templates/codespaces/devcontainer.json +52 -52
- package/templates/codespaces/setup.sh +70 -70
- package/templates/workspace/.ai/README.md +164 -164
- package/templates/workspace/.ai/agents/README.md +204 -204
- package/templates/workspace/.ai/agents/claude.md +625 -625
- package/templates/workspace/.ai/config/README.md +79 -79
- package/templates/workspace/.ai/config/notifications.template.json +20 -20
- package/templates/workspace/.ai/memory/deadends.md +79 -79
- package/templates/workspace/.ai/memory/decisions.md +58 -58
- package/templates/workspace/.ai/memory/patterns.md +65 -65
- package/templates/workspace/.ai/patterns/backend/README.md +126 -126
- package/templates/workspace/.ai/patterns/frontend/README.md +103 -103
- package/templates/workspace/.ai/patterns/index.md +256 -256
- package/templates/workspace/.ai/patterns/triggers.json +1087 -1087
- package/templates/workspace/.ai/patterns/universal/README.md +141 -141
- package/templates/workspace/.ai/state.template.json +8 -8
- package/templates/workspace/.ai/tasks/active.md +77 -77
- package/templates/workspace/.ai/tasks/backlog.md +95 -95
- package/templates/workspace/.ai/workflows/batch-task.md +356 -356
|
@@ -1,342 +1,342 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PWN Notification Service
|
|
3
|
-
*
|
|
4
|
-
* Multi-channel notification system for task completion alerts.
|
|
5
|
-
* Supports: ntfy.sh, desktop notifications, and extensible channels.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync, readFileSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @typedef {Object} NotificationConfig
|
|
13
|
-
* @property {boolean} enabled - Global enable/disable
|
|
14
|
-
* @property {string} defaultChannel - Default channel to use
|
|
15
|
-
* @property {Object} channels - Channel-specific configurations
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* @typedef {Object} NotificationPayload
|
|
20
|
-
* @property {string} title - Notification title
|
|
21
|
-
* @property {string} message - Notification body
|
|
22
|
-
* @property {string} [priority] - Priority level (low, default, high, urgent)
|
|
23
|
-
* @property {string[]} [tags] - Tags/emojis for the notification
|
|
24
|
-
* @property {string} [url] - Click action URL
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Default configuration
|
|
29
|
-
*/
|
|
30
|
-
const DEFAULT_CONFIG = {
|
|
31
|
-
enabled: true,
|
|
32
|
-
defaultChannel: 'desktop',
|
|
33
|
-
channels: {
|
|
34
|
-
ntfy: {
|
|
35
|
-
enabled: false,
|
|
36
|
-
server: 'https://ntfy.sh',
|
|
37
|
-
topic: '',
|
|
38
|
-
priority: 'default'
|
|
39
|
-
},
|
|
40
|
-
desktop: {
|
|
41
|
-
enabled: true
|
|
42
|
-
},
|
|
43
|
-
pushover: {
|
|
44
|
-
enabled: false,
|
|
45
|
-
userKey: '',
|
|
46
|
-
apiToken: ''
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Load notification configuration from workspace
|
|
53
|
-
* @param {string} cwd - Working directory
|
|
54
|
-
* @returns {NotificationConfig}
|
|
55
|
-
*/
|
|
56
|
-
export function loadConfig(cwd = process.cwd()) {
|
|
57
|
-
const configPath = join(cwd, '.ai', 'config', 'notifications.json');
|
|
58
|
-
|
|
59
|
-
if (!existsSync(configPath)) {
|
|
60
|
-
return DEFAULT_CONFIG;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
const content = readFileSync(configPath, 'utf8');
|
|
65
|
-
const userConfig = JSON.parse(content);
|
|
66
|
-
|
|
67
|
-
// Merge with defaults
|
|
68
|
-
return {
|
|
69
|
-
...DEFAULT_CONFIG,
|
|
70
|
-
...userConfig,
|
|
71
|
-
channels: {
|
|
72
|
-
...DEFAULT_CONFIG.channels,
|
|
73
|
-
...userConfig.channels
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
} catch {
|
|
77
|
-
return DEFAULT_CONFIG;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Send notification through configured channels
|
|
83
|
-
* @param {NotificationPayload} payload - Notification data
|
|
84
|
-
* @param {Object} options - Send options
|
|
85
|
-
* @param {string} [options.channel] - Specific channel to use
|
|
86
|
-
* @param {string} [options.cwd] - Working directory
|
|
87
|
-
* @returns {Promise<{success: boolean, channel: string, error?: string}>}
|
|
88
|
-
*/
|
|
89
|
-
export async function send(payload, options = {}) {
|
|
90
|
-
const { channel: specificChannel, cwd = process.cwd() } = options;
|
|
91
|
-
const config = loadConfig(cwd);
|
|
92
|
-
|
|
93
|
-
if (!config.enabled) {
|
|
94
|
-
return { success: false, channel: 'none', error: 'Notifications disabled' };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const channelName = specificChannel || config.defaultChannel;
|
|
98
|
-
const channelConfig = config.channels[channelName];
|
|
99
|
-
|
|
100
|
-
if (!channelConfig) {
|
|
101
|
-
return { success: false, channel: channelName, error: `Unknown channel: ${channelName}` };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (!channelConfig.enabled) {
|
|
105
|
-
return { success: false, channel: channelName, error: `Channel ${channelName} is disabled` };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
switch (channelName) {
|
|
110
|
-
case 'ntfy':
|
|
111
|
-
return await sendNtfy(payload, channelConfig);
|
|
112
|
-
case 'desktop':
|
|
113
|
-
return await sendDesktop(payload, channelConfig);
|
|
114
|
-
case 'pushover':
|
|
115
|
-
return await sendPushover(payload, channelConfig);
|
|
116
|
-
default:
|
|
117
|
-
return { success: false, channel: channelName, error: `No handler for channel: ${channelName}` };
|
|
118
|
-
}
|
|
119
|
-
} catch (error) {
|
|
120
|
-
return { success: false, channel: channelName, error: error.message };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Send notification via ntfy.sh
|
|
126
|
-
* @param {NotificationPayload} payload
|
|
127
|
-
* @param {Object} config
|
|
128
|
-
*/
|
|
129
|
-
async function sendNtfy(payload, config) {
|
|
130
|
-
const { server = 'https://ntfy.sh', topic, priority = 'default' } = config;
|
|
131
|
-
|
|
132
|
-
if (!topic) {
|
|
133
|
-
return { success: false, channel: 'ntfy', error: 'ntfy topic not configured' };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const url = `${server}/${topic}`;
|
|
137
|
-
|
|
138
|
-
const headers = {
|
|
139
|
-
'Title': payload.title,
|
|
140
|
-
'Priority': payload.priority || priority,
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
if (payload.tags && payload.tags.length > 0) {
|
|
144
|
-
headers['Tags'] = payload.tags.join(',');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (payload.url) {
|
|
148
|
-
headers['Click'] = payload.url;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const response = await fetch(url, {
|
|
152
|
-
method: 'POST',
|
|
153
|
-
headers,
|
|
154
|
-
body: payload.message
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
if (!response.ok) {
|
|
158
|
-
return { success: false, channel: 'ntfy', error: `HTTP ${response.status}` };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return { success: true, channel: 'ntfy' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Send desktop notification
|
|
166
|
-
* Uses native OS notifications via child process
|
|
167
|
-
* @param {NotificationPayload} payload
|
|
168
|
-
* @param {Object} config
|
|
169
|
-
*/
|
|
170
|
-
async function sendDesktop(payload, config) {
|
|
171
|
-
const { title, message } = payload;
|
|
172
|
-
|
|
173
|
-
// Use PowerShell on Windows for native toast notifications
|
|
174
|
-
if (process.platform === 'win32') {
|
|
175
|
-
const { exec } = await import('child_process');
|
|
176
|
-
const { promisify } = await import('util');
|
|
177
|
-
const execAsync = promisify(exec);
|
|
178
|
-
|
|
179
|
-
// Escape quotes in message
|
|
180
|
-
const escapedTitle = title.replace(/"/g, '`"');
|
|
181
|
-
const escapedMessage = message.replace(/"/g, '`"');
|
|
182
|
-
|
|
183
|
-
const script = `
|
|
184
|
-
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
185
|
-
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
186
|
-
$template = @"
|
|
187
|
-
<toast>
|
|
188
|
-
<visual>
|
|
189
|
-
<binding template="ToastText02">
|
|
190
|
-
<text id="1">${escapedTitle}</text>
|
|
191
|
-
<text id="2">${escapedMessage}</text>
|
|
192
|
-
</binding>
|
|
193
|
-
</visual>
|
|
194
|
-
</toast>
|
|
195
|
-
"@
|
|
196
|
-
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
197
|
-
$xml.LoadXml($template)
|
|
198
|
-
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
199
|
-
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("PWN").Show($toast)
|
|
200
|
-
`;
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
await execAsync(`powershell -Command "${script.replace(/\n/g, ' ')}"`, {
|
|
204
|
-
windowsHide: true
|
|
205
|
-
});
|
|
206
|
-
return { success: true, channel: 'desktop' };
|
|
207
|
-
} catch (error) {
|
|
208
|
-
// Fallback to simple msg command
|
|
209
|
-
try {
|
|
210
|
-
await execAsync(`msg %username% "${escapedTitle}: ${escapedMessage}"`, {
|
|
211
|
-
windowsHide: true
|
|
212
|
-
});
|
|
213
|
-
return { success: true, channel: 'desktop' };
|
|
214
|
-
} catch {
|
|
215
|
-
return { success: false, channel: 'desktop', error: 'Desktop notifications not available' };
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// macOS
|
|
221
|
-
if (process.platform === 'darwin') {
|
|
222
|
-
const { exec } = await import('child_process');
|
|
223
|
-
const { promisify } = await import('util');
|
|
224
|
-
const execAsync = promisify(exec);
|
|
225
|
-
|
|
226
|
-
const escapedTitle = title.replace(/"/g, '\\"');
|
|
227
|
-
const escapedMessage = message.replace(/"/g, '\\"');
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
await execAsync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`);
|
|
231
|
-
return { success: true, channel: 'desktop' };
|
|
232
|
-
} catch (error) {
|
|
233
|
-
return { success: false, channel: 'desktop', error: error.message };
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Linux (notify-send)
|
|
238
|
-
if (process.platform === 'linux') {
|
|
239
|
-
const { exec } = await import('child_process');
|
|
240
|
-
const { promisify } = await import('util');
|
|
241
|
-
const execAsync = promisify(exec);
|
|
242
|
-
|
|
243
|
-
try {
|
|
244
|
-
await execAsync(`notify-send "${title}" "${message}"`);
|
|
245
|
-
return { success: true, channel: 'desktop' };
|
|
246
|
-
} catch (error) {
|
|
247
|
-
return { success: false, channel: 'desktop', error: 'notify-send not available' };
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return { success: false, channel: 'desktop', error: `Unsupported platform: ${process.platform}` };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Send notification via Pushover
|
|
256
|
-
* @param {NotificationPayload} payload
|
|
257
|
-
* @param {Object} config
|
|
258
|
-
*/
|
|
259
|
-
async function sendPushover(payload, config) {
|
|
260
|
-
const { userKey, apiToken } = config;
|
|
261
|
-
|
|
262
|
-
if (!userKey || !apiToken) {
|
|
263
|
-
return { success: false, channel: 'pushover', error: 'Pushover credentials not configured' };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const priorityMap = {
|
|
267
|
-
'low': -1,
|
|
268
|
-
'default': 0,
|
|
269
|
-
'high': 1,
|
|
270
|
-
'urgent': 2
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const body = new URLSearchParams({
|
|
274
|
-
token: apiToken,
|
|
275
|
-
user: userKey,
|
|
276
|
-
title: payload.title,
|
|
277
|
-
message: payload.message,
|
|
278
|
-
priority: priorityMap[payload.priority || 'default'] || 0
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
if (payload.url) {
|
|
282
|
-
body.append('url', payload.url);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
|
286
|
-
method: 'POST',
|
|
287
|
-
body
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
if (!response.ok) {
|
|
291
|
-
const data = await response.json().catch(() => ({}));
|
|
292
|
-
return { success: false, channel: 'pushover', error: data.errors?.join(', ') || `HTTP ${response.status}` };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return { success: true, channel: 'pushover' };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Test notification configuration
|
|
300
|
-
* @param {string} channel - Channel to test
|
|
301
|
-
* @param {string} cwd - Working directory
|
|
302
|
-
* @returns {Promise<{success: boolean, channel: string, error?: string}>}
|
|
303
|
-
*/
|
|
304
|
-
export async function test(channel, cwd = process.cwd()) {
|
|
305
|
-
return send({
|
|
306
|
-
title: 'PWN Test Notification',
|
|
307
|
-
message: 'If you see this, notifications are working!',
|
|
308
|
-
tags: ['white_check_mark', 'robot']
|
|
309
|
-
}, { channel, cwd });
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Send task completion notification
|
|
314
|
-
* @param {string} taskId - Task identifier
|
|
315
|
-
* @param {string} taskName - Task description
|
|
316
|
-
* @param {Object} options - Additional options
|
|
317
|
-
*/
|
|
318
|
-
export async function notifyTaskComplete(taskId, taskName, options = {}) {
|
|
319
|
-
return send({
|
|
320
|
-
title: 'Task Completed',
|
|
321
|
-
message: `${taskId}: ${taskName}`,
|
|
322
|
-
tags: ['white_check_mark'],
|
|
323
|
-
priority: 'default',
|
|
324
|
-
...options
|
|
325
|
-
}, options);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Send error notification
|
|
330
|
-
* @param {string} title - Error title
|
|
331
|
-
* @param {string} message - Error details
|
|
332
|
-
* @param {Object} options - Additional options
|
|
333
|
-
*/
|
|
334
|
-
export async function notifyError(title, message, options = {}) {
|
|
335
|
-
return send({
|
|
336
|
-
title,
|
|
337
|
-
message,
|
|
338
|
-
tags: ['x', 'warning'],
|
|
339
|
-
priority: 'high',
|
|
340
|
-
...options
|
|
341
|
-
}, options);
|
|
342
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* PWN Notification Service
|
|
3
|
+
*
|
|
4
|
+
* Multi-channel notification system for task completion alerts.
|
|
5
|
+
* Supports: ntfy.sh, desktop notifications, and extensible channels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} NotificationConfig
|
|
13
|
+
* @property {boolean} enabled - Global enable/disable
|
|
14
|
+
* @property {string} defaultChannel - Default channel to use
|
|
15
|
+
* @property {Object} channels - Channel-specific configurations
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} NotificationPayload
|
|
20
|
+
* @property {string} title - Notification title
|
|
21
|
+
* @property {string} message - Notification body
|
|
22
|
+
* @property {string} [priority] - Priority level (low, default, high, urgent)
|
|
23
|
+
* @property {string[]} [tags] - Tags/emojis for the notification
|
|
24
|
+
* @property {string} [url] - Click action URL
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default configuration
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_CONFIG = {
|
|
31
|
+
enabled: true,
|
|
32
|
+
defaultChannel: 'desktop',
|
|
33
|
+
channels: {
|
|
34
|
+
ntfy: {
|
|
35
|
+
enabled: false,
|
|
36
|
+
server: 'https://ntfy.sh',
|
|
37
|
+
topic: '',
|
|
38
|
+
priority: 'default'
|
|
39
|
+
},
|
|
40
|
+
desktop: {
|
|
41
|
+
enabled: true
|
|
42
|
+
},
|
|
43
|
+
pushover: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
userKey: '',
|
|
46
|
+
apiToken: ''
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load notification configuration from workspace
|
|
53
|
+
* @param {string} cwd - Working directory
|
|
54
|
+
* @returns {NotificationConfig}
|
|
55
|
+
*/
|
|
56
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
57
|
+
const configPath = join(cwd, '.ai', 'config', 'notifications.json');
|
|
58
|
+
|
|
59
|
+
if (!existsSync(configPath)) {
|
|
60
|
+
return DEFAULT_CONFIG;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const content = readFileSync(configPath, 'utf8');
|
|
65
|
+
const userConfig = JSON.parse(content);
|
|
66
|
+
|
|
67
|
+
// Merge with defaults
|
|
68
|
+
return {
|
|
69
|
+
...DEFAULT_CONFIG,
|
|
70
|
+
...userConfig,
|
|
71
|
+
channels: {
|
|
72
|
+
...DEFAULT_CONFIG.channels,
|
|
73
|
+
...userConfig.channels
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
} catch {
|
|
77
|
+
return DEFAULT_CONFIG;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send notification through configured channels
|
|
83
|
+
* @param {NotificationPayload} payload - Notification data
|
|
84
|
+
* @param {Object} options - Send options
|
|
85
|
+
* @param {string} [options.channel] - Specific channel to use
|
|
86
|
+
* @param {string} [options.cwd] - Working directory
|
|
87
|
+
* @returns {Promise<{success: boolean, channel: string, error?: string}>}
|
|
88
|
+
*/
|
|
89
|
+
export async function send(payload, options = {}) {
|
|
90
|
+
const { channel: specificChannel, cwd = process.cwd() } = options;
|
|
91
|
+
const config = loadConfig(cwd);
|
|
92
|
+
|
|
93
|
+
if (!config.enabled) {
|
|
94
|
+
return { success: false, channel: 'none', error: 'Notifications disabled' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const channelName = specificChannel || config.defaultChannel;
|
|
98
|
+
const channelConfig = config.channels[channelName];
|
|
99
|
+
|
|
100
|
+
if (!channelConfig) {
|
|
101
|
+
return { success: false, channel: channelName, error: `Unknown channel: ${channelName}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!channelConfig.enabled) {
|
|
105
|
+
return { success: false, channel: channelName, error: `Channel ${channelName} is disabled` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
switch (channelName) {
|
|
110
|
+
case 'ntfy':
|
|
111
|
+
return await sendNtfy(payload, channelConfig);
|
|
112
|
+
case 'desktop':
|
|
113
|
+
return await sendDesktop(payload, channelConfig);
|
|
114
|
+
case 'pushover':
|
|
115
|
+
return await sendPushover(payload, channelConfig);
|
|
116
|
+
default:
|
|
117
|
+
return { success: false, channel: channelName, error: `No handler for channel: ${channelName}` };
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { success: false, channel: channelName, error: error.message };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Send notification via ntfy.sh
|
|
126
|
+
* @param {NotificationPayload} payload
|
|
127
|
+
* @param {Object} config
|
|
128
|
+
*/
|
|
129
|
+
async function sendNtfy(payload, config) {
|
|
130
|
+
const { server = 'https://ntfy.sh', topic, priority = 'default' } = config;
|
|
131
|
+
|
|
132
|
+
if (!topic) {
|
|
133
|
+
return { success: false, channel: 'ntfy', error: 'ntfy topic not configured' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const url = `${server}/${topic}`;
|
|
137
|
+
|
|
138
|
+
const headers = {
|
|
139
|
+
'Title': payload.title,
|
|
140
|
+
'Priority': payload.priority || priority,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (payload.tags && payload.tags.length > 0) {
|
|
144
|
+
headers['Tags'] = payload.tags.join(',');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (payload.url) {
|
|
148
|
+
headers['Click'] = payload.url;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const response = await fetch(url, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers,
|
|
154
|
+
body: payload.message
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
return { success: false, channel: 'ntfy', error: `HTTP ${response.status}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { success: true, channel: 'ntfy' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Send desktop notification
|
|
166
|
+
* Uses native OS notifications via child process
|
|
167
|
+
* @param {NotificationPayload} payload
|
|
168
|
+
* @param {Object} config
|
|
169
|
+
*/
|
|
170
|
+
async function sendDesktop(payload, config) {
|
|
171
|
+
const { title, message } = payload;
|
|
172
|
+
|
|
173
|
+
// Use PowerShell on Windows for native toast notifications
|
|
174
|
+
if (process.platform === 'win32') {
|
|
175
|
+
const { exec } = await import('child_process');
|
|
176
|
+
const { promisify } = await import('util');
|
|
177
|
+
const execAsync = promisify(exec);
|
|
178
|
+
|
|
179
|
+
// Escape quotes in message
|
|
180
|
+
const escapedTitle = title.replace(/"/g, '`"');
|
|
181
|
+
const escapedMessage = message.replace(/"/g, '`"');
|
|
182
|
+
|
|
183
|
+
const script = `
|
|
184
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
185
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
186
|
+
$template = @"
|
|
187
|
+
<toast>
|
|
188
|
+
<visual>
|
|
189
|
+
<binding template="ToastText02">
|
|
190
|
+
<text id="1">${escapedTitle}</text>
|
|
191
|
+
<text id="2">${escapedMessage}</text>
|
|
192
|
+
</binding>
|
|
193
|
+
</visual>
|
|
194
|
+
</toast>
|
|
195
|
+
"@
|
|
196
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
197
|
+
$xml.LoadXml($template)
|
|
198
|
+
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
199
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("PWN").Show($toast)
|
|
200
|
+
`;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await execAsync(`powershell -Command "${script.replace(/\n/g, ' ')}"`, {
|
|
204
|
+
windowsHide: true
|
|
205
|
+
});
|
|
206
|
+
return { success: true, channel: 'desktop' };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
// Fallback to simple msg command
|
|
209
|
+
try {
|
|
210
|
+
await execAsync(`msg %username% "${escapedTitle}: ${escapedMessage}"`, {
|
|
211
|
+
windowsHide: true
|
|
212
|
+
});
|
|
213
|
+
return { success: true, channel: 'desktop' };
|
|
214
|
+
} catch {
|
|
215
|
+
return { success: false, channel: 'desktop', error: 'Desktop notifications not available' };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// macOS
|
|
221
|
+
if (process.platform === 'darwin') {
|
|
222
|
+
const { exec } = await import('child_process');
|
|
223
|
+
const { promisify } = await import('util');
|
|
224
|
+
const execAsync = promisify(exec);
|
|
225
|
+
|
|
226
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
227
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await execAsync(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`);
|
|
231
|
+
return { success: true, channel: 'desktop' };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { success: false, channel: 'desktop', error: error.message };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Linux (notify-send)
|
|
238
|
+
if (process.platform === 'linux') {
|
|
239
|
+
const { exec } = await import('child_process');
|
|
240
|
+
const { promisify } = await import('util');
|
|
241
|
+
const execAsync = promisify(exec);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await execAsync(`notify-send "${title}" "${message}"`);
|
|
245
|
+
return { success: true, channel: 'desktop' };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { success: false, channel: 'desktop', error: 'notify-send not available' };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { success: false, channel: 'desktop', error: `Unsupported platform: ${process.platform}` };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Send notification via Pushover
|
|
256
|
+
* @param {NotificationPayload} payload
|
|
257
|
+
* @param {Object} config
|
|
258
|
+
*/
|
|
259
|
+
async function sendPushover(payload, config) {
|
|
260
|
+
const { userKey, apiToken } = config;
|
|
261
|
+
|
|
262
|
+
if (!userKey || !apiToken) {
|
|
263
|
+
return { success: false, channel: 'pushover', error: 'Pushover credentials not configured' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const priorityMap = {
|
|
267
|
+
'low': -1,
|
|
268
|
+
'default': 0,
|
|
269
|
+
'high': 1,
|
|
270
|
+
'urgent': 2
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const body = new URLSearchParams({
|
|
274
|
+
token: apiToken,
|
|
275
|
+
user: userKey,
|
|
276
|
+
title: payload.title,
|
|
277
|
+
message: payload.message,
|
|
278
|
+
priority: priorityMap[payload.priority || 'default'] || 0
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (payload.url) {
|
|
282
|
+
body.append('url', payload.url);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
body
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
const data = await response.json().catch(() => ({}));
|
|
292
|
+
return { success: false, channel: 'pushover', error: data.errors?.join(', ') || `HTTP ${response.status}` };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { success: true, channel: 'pushover' };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Test notification configuration
|
|
300
|
+
* @param {string} channel - Channel to test
|
|
301
|
+
* @param {string} cwd - Working directory
|
|
302
|
+
* @returns {Promise<{success: boolean, channel: string, error?: string}>}
|
|
303
|
+
*/
|
|
304
|
+
export async function test(channel, cwd = process.cwd()) {
|
|
305
|
+
return send({
|
|
306
|
+
title: 'PWN Test Notification',
|
|
307
|
+
message: 'If you see this, notifications are working!',
|
|
308
|
+
tags: ['white_check_mark', 'robot']
|
|
309
|
+
}, { channel, cwd });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Send task completion notification
|
|
314
|
+
* @param {string} taskId - Task identifier
|
|
315
|
+
* @param {string} taskName - Task description
|
|
316
|
+
* @param {Object} options - Additional options
|
|
317
|
+
*/
|
|
318
|
+
export async function notifyTaskComplete(taskId, taskName, options = {}) {
|
|
319
|
+
return send({
|
|
320
|
+
title: 'Task Completed',
|
|
321
|
+
message: `${taskId}: ${taskName}`,
|
|
322
|
+
tags: ['white_check_mark'],
|
|
323
|
+
priority: 'default',
|
|
324
|
+
...options
|
|
325
|
+
}, options);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Send error notification
|
|
330
|
+
* @param {string} title - Error title
|
|
331
|
+
* @param {string} message - Error details
|
|
332
|
+
* @param {Object} options - Additional options
|
|
333
|
+
*/
|
|
334
|
+
export async function notifyError(title, message, options = {}) {
|
|
335
|
+
return send({
|
|
336
|
+
title,
|
|
337
|
+
message,
|
|
338
|
+
tags: ['x', 'warning'],
|
|
339
|
+
priority: 'high',
|
|
340
|
+
...options
|
|
341
|
+
}, options);
|
|
342
|
+
}
|