@ulthon/ul-opencode-event 0.1.0 → 0.1.3
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 +60 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +322 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +151 -0
- package/dist/email.d.ts +1 -1
- package/dist/email.js +56 -0
- package/dist/handler.d.ts +1 -1
- package/dist/handler.js +78 -0
- package/dist/index.js +101 -10061
- package/dist/template.js +9 -0
- package/dist/types.js +5 -0
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -135,6 +135,58 @@ You can configure multiple channels, and all enabled channels will receive notif
|
|
|
135
135
|
}
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
+
## CLI Tool
|
|
139
|
+
|
|
140
|
+
This package includes a CLI tool to test your notification channel configuration before running OpenCode.
|
|
141
|
+
|
|
142
|
+
### Test Command
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Test all channels (validates config, tests connection, sends test email)
|
|
146
|
+
npx @ulthon/ul-opencode-event test
|
|
147
|
+
|
|
148
|
+
# Test a specific channel by name
|
|
149
|
+
npx @ulthon/ul-opencode-event test --channel "My Email"
|
|
150
|
+
|
|
151
|
+
# Only verify connection without sending email
|
|
152
|
+
npx @ulthon/ul-opencode-event test --no-send
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### What the test command does:
|
|
156
|
+
|
|
157
|
+
1. **Configuration validation**: Checks if the config file exists and has valid format
|
|
158
|
+
2. **SMTP connection test**: Verifies that the SMTP server is reachable and credentials work
|
|
159
|
+
3. **Test email**: Sends a test email to configured recipients (unless `--no-send` is used)
|
|
160
|
+
|
|
161
|
+
### Test output example:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
[ul-opencode-event-cli] Starting channel test...
|
|
165
|
+
[ul-opencode-event-cli] Loaded project config: ./ul-opencode-event.json
|
|
166
|
+
[ul-opencode-event-cli] Found 1 channel(s) to test
|
|
167
|
+
|
|
168
|
+
[ul-opencode-event-cli] Testing channel: "My Email"
|
|
169
|
+
Type: smtp
|
|
170
|
+
Recipients: user@example.com
|
|
171
|
+
|
|
172
|
+
[1/3] Validating configuration...
|
|
173
|
+
[OK] Configuration is valid
|
|
174
|
+
|
|
175
|
+
[2/3] Testing SMTP connection to smtp.example.com:465...
|
|
176
|
+
[OK] Connection successful
|
|
177
|
+
|
|
178
|
+
[3/3] Sending test email...
|
|
179
|
+
[OK] Test email sent successfully
|
|
180
|
+
Message ID: <xxx>
|
|
181
|
+
|
|
182
|
+
[SUCCESS] Channel "My Email" passed all tests
|
|
183
|
+
Please check the inbox of: user@example.com
|
|
184
|
+
|
|
185
|
+
[ul-opencode-event-cli] Test Summary:
|
|
186
|
+
Passed: 1
|
|
187
|
+
Failed: 0
|
|
188
|
+
```
|
|
189
|
+
|
|
138
190
|
## Troubleshooting
|
|
139
191
|
|
|
140
192
|
### No notifications sent
|
|
@@ -142,6 +194,14 @@ You can configure multiple channels, and all enabled channels will receive notif
|
|
|
142
194
|
1. Check if `enabled: true` is set
|
|
143
195
|
2. Check if the event type is enabled in `events`
|
|
144
196
|
3. Check SMTP credentials are correct
|
|
197
|
+
4. Ensure plugin config file exists at `.opencode/ul-opencode-event.json`, `./ul-opencode-event.json`, or `~/.config/opencode/ul-opencode-event.json`
|
|
198
|
+
5. Enable debug logs with environment variable `UL_OPENCODE_EVENT_DEBUG=1`
|
|
199
|
+
|
|
200
|
+
### How to view logs
|
|
201
|
+
|
|
202
|
+
- Start OpenCode from terminal to view stdout/stderr logs
|
|
203
|
+
- Plugin logs use prefix `[ul-opencode-event]`
|
|
204
|
+
- You will see errors for JSON parse failure, invalid config shape, and event handling failures
|
|
145
205
|
|
|
146
206
|
### SMTP authentication failed
|
|
147
207
|
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI tool for testing notification channel configuration
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @ulthon/ul-opencode-event test
|
|
7
|
+
* npx @ulthon/ul-opencode-event test --channel "My Channel"
|
|
8
|
+
* npx @ulthon/ul-opencode-event test --no-send # Only verify connection, don't send email
|
|
9
|
+
*/
|
|
10
|
+
import * as nodemailer from 'nodemailer';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
const CLI_PREFIX = '[ul-opencode-event-cli]';
|
|
15
|
+
/**
|
|
16
|
+
* Print usage information
|
|
17
|
+
*/
|
|
18
|
+
function printUsage() {
|
|
19
|
+
console.log(`
|
|
20
|
+
Usage: npx @ulthon/ul-opencode-event test [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--channel <name> Test only the channel with this name
|
|
24
|
+
--no-send Only verify connection, don't send test email
|
|
25
|
+
--help Show this help message
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
npx @ulthon/ul-opencode-event test
|
|
29
|
+
npx @ulthon/ul-opencode-event test --channel "Primary Email"
|
|
30
|
+
npx @ulthon/ul-opencode-event test --no-send
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse command line arguments
|
|
35
|
+
*/
|
|
36
|
+
function parseArgs() {
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
const options = {
|
|
39
|
+
noSend: false,
|
|
40
|
+
};
|
|
41
|
+
let command = null;
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const arg = args[i];
|
|
44
|
+
if (arg === '--help' || arg === '-h') {
|
|
45
|
+
printUsage();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
else if (arg === '--channel') {
|
|
49
|
+
options.channelName = args[++i];
|
|
50
|
+
}
|
|
51
|
+
else if (arg === '--no-send') {
|
|
52
|
+
options.noSend = true;
|
|
53
|
+
}
|
|
54
|
+
else if (!arg.startsWith('-')) {
|
|
55
|
+
command = arg;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { command, options };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validate channel configuration
|
|
62
|
+
*/
|
|
63
|
+
function validateChannel(channel) {
|
|
64
|
+
const errors = [];
|
|
65
|
+
// Check type
|
|
66
|
+
if (channel.type !== 'smtp') {
|
|
67
|
+
errors.push(`Unsupported channel type: "${channel.type}". Only "smtp" is supported.`);
|
|
68
|
+
return { valid: false, errors };
|
|
69
|
+
}
|
|
70
|
+
// Check enabled
|
|
71
|
+
if (channel.enabled === false) {
|
|
72
|
+
errors.push('Channel is disabled (enabled: false)');
|
|
73
|
+
return { valid: false, errors };
|
|
74
|
+
}
|
|
75
|
+
// Check recipients
|
|
76
|
+
if (!channel.recipients || channel.recipients.length === 0) {
|
|
77
|
+
errors.push('No recipients configured');
|
|
78
|
+
return { valid: false, errors };
|
|
79
|
+
}
|
|
80
|
+
// Check SMTP config
|
|
81
|
+
const config = channel.config;
|
|
82
|
+
if (!config.host) {
|
|
83
|
+
errors.push('SMTP host is missing');
|
|
84
|
+
}
|
|
85
|
+
if (!config.port) {
|
|
86
|
+
errors.push('SMTP port is missing');
|
|
87
|
+
}
|
|
88
|
+
if (!config.auth?.user) {
|
|
89
|
+
errors.push('SMTP auth.user is missing');
|
|
90
|
+
}
|
|
91
|
+
if (!config.auth?.pass) {
|
|
92
|
+
errors.push('SMTP auth.pass is missing');
|
|
93
|
+
}
|
|
94
|
+
return { valid: errors.length === 0, errors };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Create SMTP transporter
|
|
98
|
+
*/
|
|
99
|
+
function createTransporter(channel) {
|
|
100
|
+
const config = channel.config;
|
|
101
|
+
return nodemailer.createTransport({
|
|
102
|
+
host: config.host,
|
|
103
|
+
port: config.port,
|
|
104
|
+
secure: config.secure ?? (config.port === 465),
|
|
105
|
+
auth: {
|
|
106
|
+
user: config.auth.user,
|
|
107
|
+
pass: config.auth.pass,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Test SMTP connection
|
|
113
|
+
*/
|
|
114
|
+
async function testConnection(channel) {
|
|
115
|
+
try {
|
|
116
|
+
const transporter = createTransporter(channel);
|
|
117
|
+
await transporter.verify();
|
|
118
|
+
return { success: true };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
return { success: false, error: message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Send test email
|
|
127
|
+
*/
|
|
128
|
+
async function sendTestEmail(channel) {
|
|
129
|
+
try {
|
|
130
|
+
const config = channel.config;
|
|
131
|
+
const transporter = createTransporter(channel);
|
|
132
|
+
const fromName = channel.name || 'OpenCode Notification Test';
|
|
133
|
+
const timestamp = new Date().toISOString();
|
|
134
|
+
const result = await transporter.sendMail({
|
|
135
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
136
|
+
to: channel.recipients.join(', '),
|
|
137
|
+
subject: `[Test] OpenCode Notification Channel Test - ${timestamp}`,
|
|
138
|
+
text: `This is a test email from OpenCode notification plugin.
|
|
139
|
+
|
|
140
|
+
Channel: ${channel.name || 'unnamed'}
|
|
141
|
+
Type: ${channel.type}
|
|
142
|
+
Time: ${timestamp}
|
|
143
|
+
|
|
144
|
+
If you received this email, your notification channel is configured correctly.`,
|
|
145
|
+
html: `
|
|
146
|
+
<h2>OpenCode Notification Channel Test</h2>
|
|
147
|
+
<p>This is a test email from OpenCode notification plugin.</p>
|
|
148
|
+
<hr>
|
|
149
|
+
<ul>
|
|
150
|
+
<li><strong>Channel:</strong> ${channel.name || 'unnamed'}</li>
|
|
151
|
+
<li><strong>Type:</strong> ${channel.type}</li>
|
|
152
|
+
<li><strong>Time:</strong> ${timestamp}</li>
|
|
153
|
+
</ul>
|
|
154
|
+
<hr>
|
|
155
|
+
<p style="color: green;">If you received this email, your notification channel is configured correctly.</p>
|
|
156
|
+
`,
|
|
157
|
+
});
|
|
158
|
+
return { success: true, messageId: result.messageId };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
return { success: false, error: message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Test a single channel
|
|
167
|
+
*/
|
|
168
|
+
async function testChannel(channel, options) {
|
|
169
|
+
const channelName = channel.name || 'unnamed';
|
|
170
|
+
console.log(`\n${CLI_PREFIX} Testing channel: "${channelName}"`);
|
|
171
|
+
console.log(` Type: ${channel.type}`);
|
|
172
|
+
console.log(` Recipients: ${channel.recipients?.join(', ') || 'none'}`);
|
|
173
|
+
// Step 1: Validate configuration
|
|
174
|
+
console.log(`\n [1/3] Validating configuration...`);
|
|
175
|
+
const validation = validateChannel(channel);
|
|
176
|
+
if (!validation.valid) {
|
|
177
|
+
console.log(` [FAIL] Configuration validation failed:`);
|
|
178
|
+
validation.errors.forEach(err => console.log(` - ${err}`));
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
console.log(` [OK] Configuration is valid`);
|
|
182
|
+
// Step 2: Test connection
|
|
183
|
+
const smtpConfig = channel.config;
|
|
184
|
+
console.log(`\n [2/3] Testing SMTP connection to ${smtpConfig.host}:${smtpConfig.port}...`);
|
|
185
|
+
const connectionResult = await testConnection(channel);
|
|
186
|
+
if (!connectionResult.success) {
|
|
187
|
+
console.log(` [FAIL] Connection failed: ${connectionResult.error}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
console.log(` [OK] Connection successful`);
|
|
191
|
+
// Step 3: Send test email (if not skipped)
|
|
192
|
+
if (options.noSend) {
|
|
193
|
+
console.log(`\n [3/3] Skipping test email (--no-send flag)`);
|
|
194
|
+
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests (connection only)`);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
console.log(`\n [3/3] Sending test email...`);
|
|
198
|
+
const sendResult = await sendTestEmail(channel);
|
|
199
|
+
if (!sendResult.success) {
|
|
200
|
+
console.log(` [FAIL] Failed to send test email: ${sendResult.error}`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
console.log(` [OK] Test email sent successfully`);
|
|
204
|
+
console.log(` Message ID: ${sendResult.messageId}`);
|
|
205
|
+
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
|
|
206
|
+
console.log(` Please check the inbox of: ${channel.recipients?.join(', ')}`);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Load configuration (simplified version that loads from file)
|
|
211
|
+
* This is similar to config.ts but returns all channels including disabled ones
|
|
212
|
+
*/
|
|
213
|
+
function loadConfigForTesting() {
|
|
214
|
+
// Config file paths
|
|
215
|
+
const globalPath = path.join(os.homedir(), '.config', 'opencode', 'ul-opencode-event.json');
|
|
216
|
+
const projectOpencodePath = path.join(process.cwd(), '.opencode', 'ul-opencode-event.json');
|
|
217
|
+
const projectLocalPath = path.join(process.cwd(), 'ul-opencode-event.json');
|
|
218
|
+
let globalConfig = null;
|
|
219
|
+
let projectConfig = null;
|
|
220
|
+
// Load global config
|
|
221
|
+
if (fs.existsSync(globalPath)) {
|
|
222
|
+
try {
|
|
223
|
+
const content = fs.readFileSync(globalPath, 'utf-8');
|
|
224
|
+
globalConfig = JSON.parse(content);
|
|
225
|
+
console.log(`${CLI_PREFIX} Loaded global config: ${globalPath}`);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
console.error(`${CLI_PREFIX} Failed to parse global config: ${globalPath}`, error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Load project config
|
|
232
|
+
const projectPath = fs.existsSync(projectOpencodePath) ? projectOpencodePath :
|
|
233
|
+
fs.existsSync(projectLocalPath) ? projectLocalPath : null;
|
|
234
|
+
if (projectPath) {
|
|
235
|
+
try {
|
|
236
|
+
const content = fs.readFileSync(projectPath, 'utf-8');
|
|
237
|
+
projectConfig = JSON.parse(content);
|
|
238
|
+
console.log(`${CLI_PREFIX} Loaded project config: ${projectPath}`);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
console.error(`${CLI_PREFIX} Failed to parse project config: ${projectPath}`, error);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// If no configs found
|
|
245
|
+
if (!globalConfig && !projectConfig) {
|
|
246
|
+
console.error(`${CLI_PREFIX} No configuration file found.`);
|
|
247
|
+
console.error(`${CLI_PREFIX} Checked locations:`);
|
|
248
|
+
console.error(` - ${globalPath}`);
|
|
249
|
+
console.error(` - ${projectOpencodePath}`);
|
|
250
|
+
console.error(` - ${projectLocalPath}`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
// Merge channels (project overrides global by name)
|
|
254
|
+
const globalChannels = globalConfig?.channels || [];
|
|
255
|
+
const projectChannels = projectConfig?.channels || [];
|
|
256
|
+
const mergedChannels = [...globalChannels];
|
|
257
|
+
for (const projectChannel of projectChannels) {
|
|
258
|
+
if (projectChannel.name) {
|
|
259
|
+
const existingIndex = mergedChannels.findIndex(c => c.name === projectChannel.name);
|
|
260
|
+
if (existingIndex >= 0) {
|
|
261
|
+
mergedChannels[existingIndex] = projectChannel;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
mergedChannels.push(projectChannel);
|
|
266
|
+
}
|
|
267
|
+
return { channels: mergedChannels };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Main CLI entry point
|
|
271
|
+
*/
|
|
272
|
+
async function main() {
|
|
273
|
+
const { command, options } = parseArgs();
|
|
274
|
+
if (command !== 'test') {
|
|
275
|
+
console.error(`${CLI_PREFIX} Unknown command: "${command || '(none)'}"`);
|
|
276
|
+
printUsage();
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
console.log(`${CLI_PREFIX} Starting channel test...`);
|
|
280
|
+
// Load configuration
|
|
281
|
+
const config = loadConfigForTesting();
|
|
282
|
+
if (config.channels.length === 0) {
|
|
283
|
+
console.error(`${CLI_PREFIX} No channels configured.`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
// Filter channels by name if specified
|
|
287
|
+
let channelsToTest = config.channels;
|
|
288
|
+
if (options.channelName) {
|
|
289
|
+
channelsToTest = config.channels.filter(c => c.name === options.channelName);
|
|
290
|
+
if (channelsToTest.length === 0) {
|
|
291
|
+
console.error(`${CLI_PREFIX} No channel found with name: "${options.channelName}"`);
|
|
292
|
+
console.error(`${CLI_PREFIX} Available channels:`);
|
|
293
|
+
config.channels.forEach(c => console.error(` - ${c.name || 'unnamed'}`));
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
console.log(`${CLI_PREFIX} Found ${channelsToTest.length} channel(s) to test`);
|
|
298
|
+
// Test each channel
|
|
299
|
+
let successCount = 0;
|
|
300
|
+
let failCount = 0;
|
|
301
|
+
for (const channel of channelsToTest) {
|
|
302
|
+
const success = await testChannel(channel, options);
|
|
303
|
+
if (success) {
|
|
304
|
+
successCount++;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
failCount++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Summary
|
|
311
|
+
console.log(`\n${CLI_PREFIX} Test Summary:`);
|
|
312
|
+
console.log(` Passed: ${successCount}`);
|
|
313
|
+
console.log(` Failed: ${failCount}`);
|
|
314
|
+
if (failCount > 0) {
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Run CLI
|
|
319
|
+
main().catch(error => {
|
|
320
|
+
console.error(`${CLI_PREFIX} Unexpected error:`, error);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
});
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
const SUPPORTED_CHANNEL_TYPES = ['smtp'];
|
|
5
|
+
const LOG_PREFIX = '[ul-opencode-event]';
|
|
6
|
+
const DEBUG_ENABLED = process.env.UL_OPENCODE_EVENT_DEBUG === '1' || process.env.UL_OPENCODE_EVENT_DEBUG === 'true';
|
|
7
|
+
/**
|
|
8
|
+
* Default configuration returned when no config files exist
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
channels: [],
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Load and parse a JSON config file if it exists
|
|
15
|
+
*/
|
|
16
|
+
function loadConfigFile(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
22
|
+
const config = JSON.parse(content);
|
|
23
|
+
if (!Array.isArray(config.channels)) {
|
|
24
|
+
console.error(`${LOG_PREFIX} Invalid config format in ${filePath}: "channels" must be an array`);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (DEBUG_ENABLED) {
|
|
28
|
+
console.info(`${LOG_PREFIX} Loaded config: ${filePath} (${config.channels.length} channels)`);
|
|
29
|
+
}
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error(`${LOG_PREFIX} Failed to parse config file: ${filePath}`, error);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate that all channel types are supported
|
|
39
|
+
* @throws Error if any channel has an unsupported type
|
|
40
|
+
*/
|
|
41
|
+
function validateChannelTypes(channels) {
|
|
42
|
+
for (const channel of channels) {
|
|
43
|
+
if (!SUPPORTED_CHANNEL_TYPES.includes(channel.type)) {
|
|
44
|
+
throw new Error(`Unsupported channel type: "${channel.type}". Supported types: ${SUPPORTED_CHANNEL_TYPES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Deep merge channels by name
|
|
50
|
+
* - If project channel name matches global channel name, project overrides global
|
|
51
|
+
* - If no name match, project channels are appended to global channels
|
|
52
|
+
*/
|
|
53
|
+
function mergeChannels(globalChannels, projectChannels) {
|
|
54
|
+
if (!globalChannels?.length && !projectChannels?.length) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (!globalChannels?.length) {
|
|
58
|
+
return projectChannels ?? [];
|
|
59
|
+
}
|
|
60
|
+
if (!projectChannels?.length) {
|
|
61
|
+
return globalChannels;
|
|
62
|
+
}
|
|
63
|
+
const result = [...globalChannels];
|
|
64
|
+
for (const projectChannel of projectChannels) {
|
|
65
|
+
const existingIndex = result.findIndex((c) => c.name !== undefined && c.name === projectChannel.name);
|
|
66
|
+
if (existingIndex >= 0) {
|
|
67
|
+
// Override global channel with project channel (same name)
|
|
68
|
+
result[existingIndex] = projectChannel;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Append project channel (no name match)
|
|
72
|
+
result.push(projectChannel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Filter channels to only include enabled ones
|
|
79
|
+
* A channel is enabled if enabled is true or undefined (defaults to true)
|
|
80
|
+
*/
|
|
81
|
+
function filterEnabledChannels(channels) {
|
|
82
|
+
return channels.filter((channel) => channel.enabled !== false);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get global config file path: ~/.config/opencode/ul-opencode-event.json
|
|
86
|
+
*/
|
|
87
|
+
function getGlobalConfigPath() {
|
|
88
|
+
return path.join(os.homedir(), '.config', 'opencode', 'ul-opencode-event.json');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get project config file path
|
|
92
|
+
* Priority: .opencode/ul-opencode-event.json > ./ul-opencode-event.json
|
|
93
|
+
*/
|
|
94
|
+
function getProjectConfigPath() {
|
|
95
|
+
const opencodePath = path.join(process.cwd(), '.opencode', 'ul-opencode-event.json');
|
|
96
|
+
if (fs.existsSync(opencodePath)) {
|
|
97
|
+
return opencodePath;
|
|
98
|
+
}
|
|
99
|
+
const localPath = path.join(process.cwd(), 'ul-opencode-event.json');
|
|
100
|
+
if (fs.existsSync(localPath)) {
|
|
101
|
+
return localPath;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Load and merge notification configuration
|
|
107
|
+
*
|
|
108
|
+
* Config locations (in order of priority):
|
|
109
|
+
* 1. Project-level: .opencode/ul-opencode-event.json or ./ul-opencode-event.json
|
|
110
|
+
* 2. Global: ~/.config/opencode/ul-opencode-event.json
|
|
111
|
+
*
|
|
112
|
+
* Merge strategy:
|
|
113
|
+
* - Channels are merged by `name` field
|
|
114
|
+
* - If names match, project channel overrides global channel
|
|
115
|
+
* - If no name match, project channels are appended to global channels
|
|
116
|
+
*
|
|
117
|
+
* Filtering:
|
|
118
|
+
* - Only returns channels where enabled is true (or undefined, which defaults to true)
|
|
119
|
+
* - Channels with enabled: false are excluded
|
|
120
|
+
*
|
|
121
|
+
* Validation:
|
|
122
|
+
* - Channel type must be 'smtp' (only supported type in phase 1)
|
|
123
|
+
* - Throws clear error for unsupported channel types
|
|
124
|
+
*
|
|
125
|
+
* @returns Merged and filtered configuration, or default config if no files exist
|
|
126
|
+
* @throws Error if any channel has an unsupported type
|
|
127
|
+
*/
|
|
128
|
+
export function loadConfig() {
|
|
129
|
+
// Load global config
|
|
130
|
+
const globalConfigPath = getGlobalConfigPath();
|
|
131
|
+
const globalConfig = loadConfigFile(globalConfigPath);
|
|
132
|
+
// Load project config
|
|
133
|
+
const projectConfigPath = getProjectConfigPath();
|
|
134
|
+
const projectConfig = projectConfigPath ? loadConfigFile(projectConfigPath) : null;
|
|
135
|
+
// If no configs exist, return default
|
|
136
|
+
if (!globalConfig && !projectConfig) {
|
|
137
|
+
if (DEBUG_ENABLED) {
|
|
138
|
+
console.info(`${LOG_PREFIX} No config found. Checked: ${globalConfigPath}, ${path.join(process.cwd(), '.opencode', 'ul-opencode-event.json')}, ${path.join(process.cwd(), 'ul-opencode-event.json')}`);
|
|
139
|
+
}
|
|
140
|
+
return DEFAULT_CONFIG;
|
|
141
|
+
}
|
|
142
|
+
// Merge channels
|
|
143
|
+
const mergedChannels = mergeChannels(globalConfig?.channels, projectConfig?.channels);
|
|
144
|
+
// Validate channel types
|
|
145
|
+
validateChannelTypes(mergedChannels);
|
|
146
|
+
// Filter enabled channels
|
|
147
|
+
const enabledChannels = filterEnabledChannels(mergedChannels);
|
|
148
|
+
return {
|
|
149
|
+
channels: enabledChannels,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/dist/email.d.ts
CHANGED
package/dist/email.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email sender module for SMTP-based notifications
|
|
3
|
+
*/
|
|
4
|
+
import * as nodemailer from 'nodemailer';
|
|
5
|
+
/**
|
|
6
|
+
* Creates an email sender for the given SMTP channel
|
|
7
|
+
* @param channel - The channel configuration (must be SMTP type)
|
|
8
|
+
* @returns EmailSender instance or null if channel is invalid/disabled
|
|
9
|
+
*/
|
|
10
|
+
export function createEmailSender(channel) {
|
|
11
|
+
// Type guard: return null for non-smtp channels
|
|
12
|
+
if (channel.type !== 'smtp') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// Return null for disabled channels
|
|
16
|
+
if (channel.enabled === false) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Return null if no recipients configured
|
|
20
|
+
if (!channel.recipients || channel.recipients.length === 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const config = channel.config;
|
|
24
|
+
const fromName = channel.name || 'Notification';
|
|
25
|
+
// Create nodemailer transport with SMTP config
|
|
26
|
+
const transport = nodemailer.createTransport({
|
|
27
|
+
host: config.host,
|
|
28
|
+
port: config.port,
|
|
29
|
+
secure: config.secure,
|
|
30
|
+
auth: {
|
|
31
|
+
user: config.auth.user,
|
|
32
|
+
pass: config.auth.pass,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
// Set security options
|
|
36
|
+
transport.set('disableFileAccess', true);
|
|
37
|
+
transport.set('disableUrlAccess', true);
|
|
38
|
+
return {
|
|
39
|
+
async send(subject, body) {
|
|
40
|
+
try {
|
|
41
|
+
await transport.sendMail({
|
|
42
|
+
from: `"${fromName}" <${config.auth.user}>`,
|
|
43
|
+
to: channel.recipients.join(', '),
|
|
44
|
+
subject,
|
|
45
|
+
html: body,
|
|
46
|
+
text: body,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Silent failure: log error but don't throw
|
|
51
|
+
const timestamp = new Date().toISOString();
|
|
52
|
+
console.error(`[${timestamp}] [${fromName}] Failed to send email:`, error);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-channel event handler for notifications
|
|
3
3
|
*/
|
|
4
|
-
import type { NotificationConfig, EventPayload, EventType } from './types';
|
|
4
|
+
import type { NotificationConfig, EventPayload, EventType } from './types.js';
|
|
5
5
|
export interface EventHandler {
|
|
6
6
|
handle: (eventType: EventType, payload: EventPayload) => Promise<void>;
|
|
7
7
|
}
|