@ulthon/ul-opencode-event 0.1.0 → 0.1.1

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 CHANGED
@@ -142,6 +142,14 @@ You can configure multiple channels, and all enabled channels will receive notif
142
142
  1. Check if `enabled: true` is set
143
143
  2. Check if the event type is enabled in `events`
144
144
  3. Check SMTP credentials are correct
145
+ 4. Ensure plugin config file exists at `.opencode/ul-opencode-event.json`, `./ul-opencode-event.json`, or `~/.config/opencode/ul-opencode-event.json`
146
+ 5. Enable debug logs with environment variable `UL_OPENCODE_EVENT_DEBUG=1`
147
+
148
+ ### How to view logs
149
+
150
+ - Start OpenCode from terminal to view stdout/stderr logs
151
+ - Plugin logs use prefix `[ul-opencode-event]`
152
+ - You will see errors for JSON parse failure, invalid config shape, and event handling failures
145
153
 
146
154
  ### SMTP authentication failed
147
155
 
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { NotificationConfig } from './types';
1
+ import type { NotificationConfig } from './types.js';
2
2
  /**
3
3
  * Load and merge notification configuration
4
4
  *
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
@@ -1,4 +1,4 @@
1
- import type { Channel } from './types';
1
+ import type { Channel } from './types.js';
2
2
  export interface EmailSender {
3
3
  send: (subject: string, body: string) => Promise<void>;
4
4
  }
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
  }
@@ -0,0 +1,78 @@
1
+ import { renderTemplate } from './template.js';
2
+ import { createEmailSender } from './email.js';
3
+ // Default templates when channel doesn't have custom templates
4
+ const defaultTemplates = {
5
+ created: {
6
+ subject: '[OpenCode] Task Started - {{projectName}}',
7
+ body: 'Task started at {{timestamp}}\nProject: {{projectName}}\nSession: {{sessionId}}',
8
+ },
9
+ idle: {
10
+ subject: '[OpenCode] Task Completed - {{projectName}}',
11
+ body: 'Task completed at {{timestamp}}\nDuration: {{duration}}\nMessage: {{message}}',
12
+ },
13
+ error: {
14
+ subject: '[OpenCode] Task Error - {{projectName}}',
15
+ body: 'Error at {{timestamp}}\nError: {{error}}',
16
+ },
17
+ };
18
+ /**
19
+ * Creates an event handler that dispatches notifications to all enabled channels
20
+ */
21
+ export function createEventHandler(config) {
22
+ return {
23
+ handle: async (eventType, payload) => {
24
+ // Process each channel
25
+ for (const channel of config.channels) {
26
+ // Skip disabled channels
27
+ if (channel.enabled === false) {
28
+ continue;
29
+ }
30
+ // Check if this event type is enabled for the channel
31
+ const eventEnabled = channel.events[eventType];
32
+ if (eventEnabled === false) {
33
+ continue;
34
+ }
35
+ // Get template (channel-specific or default)
36
+ const template = channel.templates?.[eventType] ?? defaultTemplates[eventType];
37
+ // Prepare template variables from payload
38
+ const variables = {
39
+ eventType: payload.eventType,
40
+ timestamp: payload.timestamp,
41
+ projectName: payload.projectName,
42
+ sessionId: payload.sessionId,
43
+ message: payload.message,
44
+ error: payload.error,
45
+ duration: payload.duration,
46
+ };
47
+ // Render templates
48
+ const subject = template.subject ? renderTemplate(template.subject, variables) : '';
49
+ const body = template.body ? renderTemplate(template.body, variables) : '';
50
+ // Dispatch based on channel type
51
+ await dispatchToChannel(channel, subject, body);
52
+ }
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Dispatch notification to the appropriate channel sender
58
+ */
59
+ async function dispatchToChannel(channel, subject, body) {
60
+ switch (channel.type) {
61
+ case 'smtp': {
62
+ const sender = createEmailSender(channel);
63
+ if (sender) {
64
+ await sender.send(subject, body);
65
+ }
66
+ break;
67
+ }
68
+ case 'feishu':
69
+ case 'dingtalk':
70
+ // Skip unsupported channel types silently
71
+ break;
72
+ default: {
73
+ // Log warning for unknown channel types
74
+ const _exhaustiveCheck = channel.type;
75
+ console.warn(`[EventHandler] Unsupported channel type: ${_exhaustiveCheck}`);
76
+ }
77
+ }
78
+ }