@ulthon/ul-opencode-event 0.1.17 → 0.1.19

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/dist/config.js CHANGED
@@ -180,6 +180,7 @@ export function loadConfig() {
180
180
  // Merge top-level fields (language, timezone) — project config takes priority
181
181
  const mergedLanguage = projectConfig?.language ?? globalConfig?.language;
182
182
  const mergedTimezone = projectConfig?.timezone ?? globalConfig?.timezone;
183
+ const mergedAutoUpdate = projectConfig?.auto_update ?? globalConfig?.auto_update;
183
184
  // Merge includeSessionTypes — project overrides global
184
185
  const rawIncludeSessionTypes = projectConfig?.includeSessionTypes ?? globalConfig?.includeSessionTypes;
185
186
  // Deprecation warning for old field
@@ -192,6 +193,7 @@ export function loadConfig() {
192
193
  channels: enabledChannels,
193
194
  ...(mergedLanguage !== undefined && { language: mergedLanguage }),
194
195
  ...(mergedTimezone !== undefined && { timezone: mergedTimezone }),
196
+ ...(mergedAutoUpdate !== undefined ? { auto_update: mergedAutoUpdate } : { auto_update: true }),
195
197
  includeSessionTypes: validateIncludeSessionTypes(rawIncludeSessionTypes),
196
198
  };
197
199
  }
package/dist/dingtalk.js CHANGED
@@ -91,8 +91,8 @@ export function createDingTalkFormatter(config) {
91
91
  // Default: escape special characters and pass through
92
92
  processedLines.push(escapeMarkdown(trimmed));
93
93
  }
94
- // 3. Join and truncate
95
- let text = processedLines.join('\n');
94
+ // 3. Join with double newlines (DingTalk Markdown requires \n\n for line breaks)
95
+ let text = processedLines.join('\n\n');
96
96
  if (text.length > MAX_CONTENT_LENGTH) {
97
97
  text = text.slice(0, TRUNCATE_KEEP_LENGTH) + TRUNCATE_SUFFIX;
98
98
  }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { createEventHandler } from './handler.js';
3
3
  import { getDefaultTimezone } from './locales.js';
4
4
  import { DEFAULT_SESSION_TYPES } from './types.js';
5
5
  import { logger, DEBUG_ENABLED } from './logger.js';
6
+ import { runAutoUpdate } from './updater.js';
6
7
  /**
7
8
  * Resolve session type from SDK Session info.
8
9
  * - undefined/missing sessionInfo → 'unknown' (race condition or file.edited)
@@ -204,6 +205,10 @@ function formatError(error) {
204
205
  }
205
206
  return String(error);
206
207
  }
208
+ /**
209
+ * Guard flag to ensure auto-update only runs once per plugin load
210
+ */
211
+ let hasCheckedUpdate = false;
207
212
  /**
208
213
  * Notification Plugin for OpenCode
209
214
  * Listens for session events and sends notifications via configured channels
@@ -238,6 +243,20 @@ export const NotificationPlugin = async (ctx) => {
238
243
  }
239
244
  if (event.type === 'session.created') {
240
245
  sessionMap.set(event.properties.info.id, event.properties.info);
246
+ // Auto-update check (runs once, first main session only)
247
+ if (!hasCheckedUpdate) {
248
+ hasCheckedUpdate = true;
249
+ const info = event.properties.info;
250
+ const isCliMode = process.env.OPENCODE_CLI_RUN_MODE === 'true';
251
+ const isSubSession = info.parentID !== undefined && info.parentID !== '';
252
+ if (!isCliMode && !isSubSession) {
253
+ setTimeout(() => {
254
+ runAutoUpdate(ctx, config.auto_update ?? true).catch((err) => {
255
+ logger.error(`Auto-update check failed: ${err}`);
256
+ });
257
+ }, 0);
258
+ }
259
+ }
241
260
  }
242
261
  else if (event.type === 'session.updated') {
243
262
  sessionMap.set(event.properties.info.id, event.properties.info);
package/dist/types.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface NotificationConfig {
23
23
  timezone?: string;
24
24
  language?: LocaleType;
25
25
  includeSessionTypes?: SessionType[];
26
+ /** 自动更新开关(默认 true) */
27
+ auto_update?: boolean;
26
28
  }
27
29
  /**
28
30
  * Generic channel interface
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Auto-update module for @ulthon/ul-opencode-event.
3
+ *
4
+ * Checks npm registry for new versions, manages a 24h cache,
5
+ * and optionally runs `bun install` to auto-update.
6
+ *
7
+ * Zero runtime dependencies — only Node.js built-ins + project logger.
8
+ */
9
+ /**
10
+ * Read the current installed version from this module's package.json
11
+ * by walking up from `import.meta.url`.
12
+ */
13
+ export declare function getCurrentVersion(): string | null;
14
+ /**
15
+ * Fetch the latest version string from the npm dist-tags API.
16
+ * Returns `null` on any error (network, parse, timeout).
17
+ */
18
+ export declare function getLatestVersion(): Promise<string | null>;
19
+ export declare function getCachedCheck(): {
20
+ latestVersion: string;
21
+ timestamp: number;
22
+ } | null;
23
+ export declare function setCachedCheck(latestVersion: string): void;
24
+ export declare function isCacheValid(): boolean;
25
+ /**
26
+ * Execute `bun install` in the given workspace directory.
27
+ * Uses Bun.spawn if available, otherwise falls back to child_process.spawn.
28
+ */
29
+ export declare function runBunInstall(workspaceDir: string): Promise<boolean>;
30
+ /**
31
+ * Orchestrates the full auto-update flow:
32
+ * 1. Skip checks (dev mode, pinned version)
33
+ * 2. Get current version
34
+ * 3. Check cache, fetch latest if stale
35
+ * 4. Compare versions
36
+ * 5. Notify or auto-update
37
+ */
38
+ export declare function runAutoUpdate(ctx: any, autoUpdate: boolean): Promise<void>;
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Auto-update module for @ulthon/ul-opencode-event.
3
+ *
4
+ * Checks npm registry for new versions, manages a 24h cache,
5
+ * and optionally runs `bun install` to auto-update.
6
+ *
7
+ * Zero runtime dependencies — only Node.js built-ins + project logger.
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+ import { fileURLToPath } from 'url';
13
+ import { logger } from './logger.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Constants
16
+ // ---------------------------------------------------------------------------
17
+ const PACKAGE_NAME = '@ulthon/ul-opencode-event';
18
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
19
+ const NPM_FETCH_TIMEOUT = 5000; // 5 seconds
20
+ const BUN_INSTALL_TIMEOUT = 60000; // 60 seconds
21
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours in ms
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers: cache paths
24
+ // ---------------------------------------------------------------------------
25
+ function getCacheDir() {
26
+ return process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), '.local', 'share', 'opencode');
27
+ }
28
+ function getCacheFilePath() {
29
+ return path.join(getCacheDir(), 'ul-opencode-event-update-cache.json');
30
+ }
31
+ function getUserConfigDir() {
32
+ return path.join(os.homedir(), '.config', 'opencode');
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Exported: version resolution
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Read the current installed version from this module's package.json
39
+ * by walking up from `import.meta.url`.
40
+ */
41
+ export function getCurrentVersion() {
42
+ try {
43
+ let dir = path.dirname(fileURLToPath(import.meta.url));
44
+ for (let i = 0; i < 10; i++) {
45
+ const pkgPath = path.join(dir, 'package.json');
46
+ try {
47
+ const raw = fs.readFileSync(pkgPath, 'utf-8');
48
+ const pkg = JSON.parse(raw);
49
+ if (pkg.name === PACKAGE_NAME) {
50
+ return pkg.version ?? null;
51
+ }
52
+ }
53
+ catch {
54
+ // package.json not found here, walk up
55
+ }
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir)
58
+ break; // filesystem root
59
+ dir = parent;
60
+ }
61
+ return null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Exported: npm registry fetch
69
+ // ---------------------------------------------------------------------------
70
+ /**
71
+ * Fetch the latest version string from the npm dist-tags API.
72
+ * Returns `null` on any error (network, parse, timeout).
73
+ */
74
+ export async function getLatestVersion() {
75
+ try {
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
78
+ const resp = await fetch(NPM_REGISTRY_URL, {
79
+ signal: controller.signal,
80
+ headers: { Accept: 'application/json' },
81
+ });
82
+ clearTimeout(timer);
83
+ if (!resp.ok) {
84
+ logger.debug(`[updater] npm registry returned status ${resp.status}`);
85
+ return null;
86
+ }
87
+ const data = (await resp.json());
88
+ return typeof data.latest === 'string' ? data.latest : null;
89
+ }
90
+ catch (err) {
91
+ logger.debug(`[updater] Failed to fetch latest version: ${err}`);
92
+ return null;
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Exported: cache read / write / validity
97
+ // ---------------------------------------------------------------------------
98
+ export function getCachedCheck() {
99
+ try {
100
+ const raw = fs.readFileSync(getCacheFilePath(), 'utf-8');
101
+ return JSON.parse(raw);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ export function setCachedCheck(latestVersion) {
108
+ try {
109
+ const obj = { latestVersion, timestamp: Date.now() };
110
+ fs.mkdirSync(path.dirname(getCacheFilePath()), { recursive: true });
111
+ fs.writeFileSync(getCacheFilePath(), JSON.stringify(obj, null, 2), 'utf-8');
112
+ }
113
+ catch (err) {
114
+ logger.error(`[updater] Failed to write update cache: ${err}`);
115
+ }
116
+ }
117
+ export function isCacheValid() {
118
+ const cached = getCachedCheck();
119
+ if (!cached)
120
+ return false;
121
+ return Date.now() - cached.timestamp < CACHE_TTL;
122
+ }
123
+ function readPluginList(directory) {
124
+ const results = [];
125
+ const candidates = [
126
+ path.join(directory, '.opencode', 'settings.json'),
127
+ path.join(getUserConfigDir(), 'settings.json'),
128
+ ];
129
+ for (const cfgPath of candidates) {
130
+ try {
131
+ const raw = fs.readFileSync(cfgPath, 'utf-8');
132
+ const settings = JSON.parse(raw);
133
+ if (Array.isArray(settings.plugin)) {
134
+ results.push(...settings.plugin);
135
+ }
136
+ }
137
+ catch {
138
+ // file missing or invalid — skip
139
+ }
140
+ }
141
+ return results;
142
+ }
143
+ function isLocalDevMode(directory) {
144
+ try {
145
+ const plugins = readPluginList(directory);
146
+ return plugins.some((entry) => entry.startsWith('file://') && entry.includes('ul-opencode-event'));
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
153
+ function isPinnedVersion(directory) {
154
+ try {
155
+ const plugins = readPluginList(directory);
156
+ return plugins.some((entry) => {
157
+ // Match patterns like @ulthon/ul-opencode-event@1.2.3 or @ulthon/ul-opencode-event@1.2.3-beta.1
158
+ if (!entry.includes('ul-opencode-event'))
159
+ return false;
160
+ const atIndex = entry.lastIndexOf('@');
161
+ if (atIndex <= 0)
162
+ return false; // no version part or starts with @ (scoped package)
163
+ const version = entry.slice(atIndex + 1);
164
+ // For scoped packages like @ulthon/ul-opencode-event@1.2.3, the lastIndexOf('@')
165
+ // will find the second @. But we need to make sure the prefix is the scoped package.
166
+ // So we check the version part matches exact semver.
167
+ return EXACT_SEMVER_RE.test(version);
168
+ });
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // Helper: package invalidation
176
+ // ---------------------------------------------------------------------------
177
+ function invalidatePackage() {
178
+ try {
179
+ const dirs = [
180
+ path.join(getCacheDir(), 'packages', 'node_modules', PACKAGE_NAME),
181
+ path.join(getUserConfigDir(), 'node_modules', PACKAGE_NAME),
182
+ ];
183
+ for (const pkgDir of dirs) {
184
+ try {
185
+ if (fs.existsSync(pkgDir)) {
186
+ fs.rmSync(pkgDir, { recursive: true, force: true });
187
+ logger.info(`[updater] Invalidated package at ${pkgDir}`);
188
+ }
189
+ }
190
+ catch (err) {
191
+ logger.debug(`[updater] Could not remove ${pkgDir}: ${err}`);
192
+ }
193
+ }
194
+ // Also remove bun.lock / bun.lockb from workspace dirs
195
+ const workspaceDirs = [
196
+ path.join(getCacheDir(), 'packages'),
197
+ getUserConfigDir(),
198
+ ];
199
+ for (const wsDir of workspaceDirs) {
200
+ for (const lockFile of ['bun.lock', 'bun.lockb']) {
201
+ const lockPath = path.join(wsDir, lockFile);
202
+ try {
203
+ if (fs.existsSync(lockPath)) {
204
+ fs.rmSync(lockPath, { force: true });
205
+ logger.info(`[updater] Removed ${lockPath}`);
206
+ }
207
+ }
208
+ catch (err) {
209
+ logger.debug(`[updater] Could not remove ${lockPath}: ${err}`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ catch (err) {
215
+ logger.error(`[updater] Package invalidation failed: ${err}`);
216
+ }
217
+ }
218
+ // ---------------------------------------------------------------------------
219
+ // Helper: resolve workspace directory
220
+ // ---------------------------------------------------------------------------
221
+ function resolveWorkspaceDir() {
222
+ const configDir = getUserConfigDir();
223
+ const packagesDir = path.join(getCacheDir(), 'packages');
224
+ // Check if installed in config dir
225
+ const configPkg = path.join(configDir, 'node_modules', PACKAGE_NAME, 'package.json');
226
+ if (fs.existsSync(configPkg))
227
+ return configDir;
228
+ // Check packages dir (node_modules style)
229
+ const packagesPkg = path.join(packagesDir, 'node_modules', PACKAGE_NAME, 'package.json');
230
+ if (fs.existsSync(packagesPkg))
231
+ return packagesDir;
232
+ // Check packages dir (flat)
233
+ const flatPkg = path.join(packagesDir, 'package.json');
234
+ if (fs.existsSync(flatPkg))
235
+ return packagesDir;
236
+ // Default to config dir
237
+ return configDir;
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // Exported: bun install
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * Execute `bun install` in the given workspace directory.
244
+ * Uses Bun.spawn if available, otherwise falls back to child_process.spawn.
245
+ */
246
+ export async function runBunInstall(workspaceDir) {
247
+ try {
248
+ const { spawn } = await import('child_process');
249
+ return await new Promise((resolve) => {
250
+ const spawnOptions = {
251
+ cwd: workspaceDir,
252
+ windowsHide: true,
253
+ shell: true,
254
+ };
255
+ const child = spawn('bun', ['install'], spawnOptions);
256
+ let stdout = '';
257
+ let stderr = '';
258
+ child.stdout?.on('data', (data) => {
259
+ stdout += data.toString();
260
+ });
261
+ child.stderr?.on('data', (data) => {
262
+ stderr += data.toString();
263
+ });
264
+ // Timeout guard
265
+ const timer = setTimeout(() => {
266
+ child.kill('SIGKILL');
267
+ logger.warn('[updater] bun install timed out after 60s');
268
+ resolve(false);
269
+ }, BUN_INSTALL_TIMEOUT);
270
+ child.on('close', (code) => {
271
+ clearTimeout(timer);
272
+ if (code === 0) {
273
+ resolve(true);
274
+ }
275
+ else {
276
+ logger.warn(`[updater] bun install exited with code ${code}`);
277
+ if (stderr)
278
+ logger.debug(`[updater] bun install stderr: ${stderr}`);
279
+ if (stdout)
280
+ logger.debug(`[updater] bun install stdout: ${stdout}`);
281
+ resolve(false);
282
+ }
283
+ });
284
+ child.on('error', (err) => {
285
+ clearTimeout(timer);
286
+ logger.error(`[updater] bun install spawn error: ${err}`);
287
+ resolve(false);
288
+ });
289
+ });
290
+ }
291
+ catch (err) {
292
+ logger.error(`[updater] bun install failed: ${err}`);
293
+ return false;
294
+ }
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // Helper: toast notification
298
+ // ---------------------------------------------------------------------------
299
+ async function showToast(ctx, title, message, variant, duration) {
300
+ try {
301
+ await ctx.client.tui
302
+ .showToast({ body: { title, message, variant, duration } })
303
+ .catch(() => { });
304
+ }
305
+ catch {
306
+ logger.info(`[updater] ${title}: ${message}`);
307
+ }
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // Main entry point
311
+ // ---------------------------------------------------------------------------
312
+ /**
313
+ * Orchestrates the full auto-update flow:
314
+ * 1. Skip checks (dev mode, pinned version)
315
+ * 2. Get current version
316
+ * 3. Check cache, fetch latest if stale
317
+ * 4. Compare versions
318
+ * 5. Notify or auto-update
319
+ */
320
+ export async function runAutoUpdate(ctx, autoUpdate) {
321
+ try {
322
+ // 1. Skip checks
323
+ if (isLocalDevMode(ctx.directory)) {
324
+ logger.info('[updater] Local development mode detected, skipping update check');
325
+ return;
326
+ }
327
+ if (isPinnedVersion(ctx.directory)) {
328
+ logger.info('[updater] Pinned version detected, skipping auto-update');
329
+ // Still check and notify, but don't auto-install
330
+ autoUpdate = false;
331
+ }
332
+ // 2. Get current version
333
+ const currentVersion = getCurrentVersion();
334
+ if (!currentVersion) {
335
+ logger.debug('[updater] Could not determine current version');
336
+ return;
337
+ }
338
+ // 3. Check cache first
339
+ let latestVersion = null;
340
+ if (isCacheValid()) {
341
+ const cached = getCachedCheck();
342
+ if (cached) {
343
+ latestVersion = cached.latestVersion;
344
+ logger.debug(`[updater] Using cached version info: ${latestVersion}`);
345
+ }
346
+ }
347
+ // 4. Fetch latest if no valid cache
348
+ if (!latestVersion) {
349
+ latestVersion = await getLatestVersion();
350
+ if (latestVersion) {
351
+ setCachedCheck(latestVersion);
352
+ }
353
+ }
354
+ if (!latestVersion) {
355
+ logger.debug('[updater] Could not fetch latest version');
356
+ return;
357
+ }
358
+ // 5. Compare versions (simple string equality)
359
+ if (currentVersion === latestVersion) {
360
+ logger.debug(`[updater] Already on latest version: ${currentVersion}`);
361
+ return;
362
+ }
363
+ logger.info(`[updater] Update available: ${currentVersion} -> ${latestVersion}`);
364
+ // 6. If auto-update disabled, just notify
365
+ if (!autoUpdate) {
366
+ await showToast(ctx, `ul-opencode-event ${latestVersion}`, `Update available: v${currentVersion} -> v${latestVersion}\nRun: npm update @ulthon/ul-opencode-event`, 'info', 8000);
367
+ return;
368
+ }
369
+ // 7. Auto-update: invalidate + bun install
370
+ invalidatePackage();
371
+ const workspaceDir = resolveWorkspaceDir();
372
+ const success = await runBunInstall(workspaceDir);
373
+ if (success) {
374
+ await showToast(ctx, 'ul-opencode-event Updated!', `v${currentVersion} -> v${latestVersion}\nRestart OpenCode to apply.`, 'success', 8000);
375
+ logger.info(`[updater] Update installed: ${currentVersion} -> ${latestVersion}`);
376
+ }
377
+ else {
378
+ await showToast(ctx, 'ul-opencode-event Update Failed', `Failed to install v${latestVersion}. Run manually: npm update @ulthon/ul-opencode-event`, 'error', 8000);
379
+ logger.warn('[updater] bun install failed, update not installed');
380
+ }
381
+ }
382
+ catch (err) {
383
+ logger.error(`[updater] Auto-update check failed: ${err}`);
384
+ }
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulthon/ul-opencode-event",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "OpenCode notification plugin - sends notifications via email or DingTalk when session events occur",
5
5
  "author": "augushong",
6
6
  "license": "MIT",