@goodandready/dsh-cron 0.2.15 → 0.2.16

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
@@ -392,6 +392,13 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
392
392
 
393
393
 
394
394
 
395
+ ### 28. In-App One-Click Auto-Updater & Error Resilience (v0.2.16, #147, #155, #156)
396
+ - **Plugin Self-Updater (#147)**: Added in-app one-click self-updating module (`lib/updater.js`) with `/api/dsh-cron/update` endpoint and dedicated Settings UI card. Automatically queries npm registry, compares semver versions including pre-releases, and upgrades `@goodandready/dsh-cron` in-place through the DSH CLI without manual SSH sessions. POST updates are protected via origin validation (`rejectCrossOrigin`).
397
+ - **Silent Failure Elimination (#156)**: Replaced silent empty catches with diagnostic logging: uncompleted import transaction rollbacks are surfaced to system logs with warning severity, dynamic core module fallback reasons are logged, and session opening failures inform the user with actionable notifications.
398
+ - **Dead Code & Export Hygiene (#155)**: Cleaned unused legacy helpers (`CHANNEL_LABELS`, `makeInspectAsk`), stripped unnecessary exports from 16 internal modules, and wired `supportsSilentRule` directly into execution pipeline.
399
+
400
+ ---
401
+
395
402
  ## 📦 Installation
396
403
 
397
404
  Install into your DeepSeek Harness web profile:
@@ -503,6 +510,7 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
503
510
  | `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
504
511
  | `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
505
512
  | `GET` | `/dsh-cron/metrics` | Prometheus text exposition of task and run counters — never prompts or output (#53) |
513
+ | `GET` / `POST` | `/api/dsh-cron/update` | One-click plugin self-updater: query registry version and in-place upgrade (#147) |
506
514
  | `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
507
515
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
508
516
  | `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
package/README.ru.md CHANGED
@@ -268,6 +268,13 @@ cron_create_task({
268
268
 
269
269
 
270
270
 
271
+ ### 28. Модуль самообновления в 1 клик и устойчивость к ошибкам (v0.2.16, #147, #155, #156)
272
+ - **Самообновление плагина (#147)**: Добавлен встроенный модуль обновления (`lib/updater.js`) с маршрутом `/api/dsh-cron/update` и карточкой в настройках Web UI. Автоматически опрашивает npm registry, корректно сравнивает semver-версии (включая пререлизы) и обновляет `@goodandready/dsh-cron` на лету через CLI DSH без необходимости входа по SSH. Запросы POST защищены проверкой источника (`rejectCrossOrigin`).
273
+ - **Устранение скрытых сбоев (#156)**: Ликвидированы пустые `catch`: незавершённые откаты транзакций импорта логируются с уровнем `warn`, причины fallback для динамических модулей ядра фиксируются в отладочном журнале, а при сбое открытия сессии пользователь получает понятное уведомление.
274
+ - **Гигиена кода и экспортов (#155)**: Удалены устаревшие неиспользуемые сущности (`CHANNEL_LABELS`, `makeInspectAsk`), снят лишний модификатор `export` с 16 внутренних функций и констант, а метод `supportsSilentRule` напрямую подключен в боевой пайплайн проверки тихих правил.
275
+
276
+ ---
277
+
271
278
  ## 📦 Установка
272
279
 
273
280
  ```bash
@@ -500,6 +507,7 @@ bash deploy.sh verify [exact-version]
500
507
  | `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
501
508
  | `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
502
509
  | `GET` | `/dsh-cron/metrics` | Текст в формате Prometheus: счётчики задач и запусков — без промптов и вывода (#53) |
510
+ | `GET` / `POST` | `/api/dsh-cron/update` | Самообновление плагина в 1 клик: запрос версии в реестре и обновление на лету (#147) |
503
511
  | `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
504
512
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
505
513
  | `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
package/README.zh.md CHANGED
@@ -391,6 +391,13 @@ bash deploy.sh verify [exact-version]
391
391
 
392
392
 
393
393
 
394
+ ### 28. 应用内一键自更新与错误容错增强 (v0.2.16, #147, #155, #156)
395
+ - **插件自更新模块 (#147)**:新增应用内一键自更新机制 (`lib/updater.js`)、`/api/dsh-cron/update` 接口与设置面板专属卡片。自动轮询 npmjs 仓库最新版本,精准比对 Semver 版本号(含预发布版本),通过 DSH CLI 就地升级 `@goodandready/dsh-cron`,无需 SSH 终端操作。POST 请求受跨域与来源保护 (`rejectCrossOrigin`)。
396
+ - **消除静默异常与错误追踪 (#156)**:全面消除空 `catch` 异常压制:任务导入事务回滚失败记录 `warn` 级别日志、动态核心模块降级原因记录诊断日志、会话自动唤出失败向用户呈现友好提示。
397
+ - **死代码清理与模块导出精简 (#155)**:清理未引用的旧版实体 (`CHANNEL_LABELS`, `makeInspectAsk`),收回 16 个内部工具函数的暴露权限,并将 `supportsSilentRule` 接入静默规则核心执行流水线。
398
+
399
+ ---
400
+
394
401
  ## 📦 安装
395
402
 
396
403
  ```bash
@@ -500,6 +507,7 @@ dsh-cron:
500
507
  | `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
501
508
  | `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
502
509
  | `GET` | `/dsh-cron/metrics` | Prometheus 文本格式的任务与运行计数 —— 不含提示词与输出(#53) |
510
+ | `GET` / `POST` | `/api/dsh-cron/update` | 插件一键自更新:查询仓库最新版本并就地平滑升级 (#147) |
503
511
  | `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
504
512
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
505
513
  | `POST` | `/dsh-cron/api/tasks/:id/run` | 令牌保护的外部接口:强制执行(#54) |
package/lib/api.js CHANGED
@@ -343,7 +343,9 @@ function applyImportPlan({ store, scheduler, plan }) {
343
343
  try {
344
344
  if (entry.previous) store.set(entry.previous);
345
345
  else store.delete(entry.id);
346
- } catch (_) {}
346
+ } catch (rollbackErr) {
347
+ console.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
348
+ }
347
349
  }
348
350
  return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
349
351
  }
package/lib/channels.js CHANGED
@@ -13,22 +13,12 @@ import { resolveTemplateText, truncateText } from './templates.js';
13
13
 
14
14
  export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
15
15
 
16
- export const CHANNEL_LABELS = {
17
- telegram: 'Telegram',
18
- kanban: 'dsh-kanban card',
19
- discord: 'Discord webhook',
20
- slack: 'Slack webhook',
21
- ntfy: 'ntfy push',
22
- bark: 'Bark push',
23
- pushplus: 'PushPlus',
24
- tts: 'Voice via dsh-tts',
25
- gitea: 'Gitea issue',
26
- };
16
+ // CHANNEL_LABELS removed (#155) — localized via lib/client.js
27
17
 
28
18
  const isFailed = (status) => status === 'error' || status === 'timeout';
29
19
 
30
20
  /** The message text for a channel: custom template, else built-in defaults. */
31
- export function messageTextFor(channelId, task, runInfo, settings = {}) {
21
+ function messageTextFor(channelId, task, runInfo, settings = {}) {
32
22
  const templates = (settings && settings.channelTemplates) || {};
33
23
  const template = templates[channelId] || settings.template || '';
34
24
  return resolveTemplateText({ template, task, runInfo });
@@ -247,7 +237,7 @@ async function postJson(http, url, headers, body, channelId, signal, timeoutMs)
247
237
  * One handler per channel. Each returns a small detail object and throws a
248
238
  * descriptive Error on failure; the router turns that into a failure entry.
249
239
  */
250
- export const CHANNEL_HANDLERS = {
240
+ const CHANNEL_HANDLERS = {
251
241
  async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
252
242
  const botToken = secrets.botToken || '';
253
243
  const chatId = String(settings.chatId || '').trim();
package/lib/chat-start.js CHANGED
@@ -24,7 +24,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
24
24
  if (llmModule && typeof llmModule.createUserMessage === 'function') {
25
25
  createUserMessage = llmModule.createUserMessage;
26
26
  }
27
- } catch {}
27
+ } catch (err) {
28
+ (ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-llm createUserMessage unavailable, using identity fallback:', err?.message || err);
29
+ }
28
30
 
29
31
  let mintSessionId = () => `cron-setup-${randomUUID()}`;
30
32
  try {
@@ -33,7 +35,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
33
35
  const sid = mintSessionId();
34
36
  mintSessionId = () => sessionModule.SessionId(sid);
35
37
  }
36
- } catch {}
38
+ } catch (err) {
39
+ (ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-session SessionId unavailable, using string fallback:', err?.message || err);
40
+ }
37
41
 
38
42
  const defaultSel = ctx.get('agentDefaultModel')?.currentSelection?.();
39
43
  const provider = body.provider || defaultSel?.provider;
package/lib/client.js CHANGED
@@ -1088,6 +1088,7 @@ window.__ModuleLoader__.load({
1088
1088
  }
1089
1089
  }
1090
1090
  }
1091
+ console.warn('[dsh-cron] could not open session in workspace:', sessionId);
1091
1092
  return false;
1092
1093
  }
1093
1094
 
@@ -3316,6 +3317,65 @@ window.__ModuleLoader__.load({
3316
3317
  const [testInfo, setTestInfo] = React.useState(null);
3317
3318
  // Collapsed by default, head is the toggle (canonical card contract, #100)
3318
3319
  const [cardOpen, setCardOpen] = React.useState(false);
3320
+ const [updateState, setUpdateState] = React.useState({
3321
+ checking: false,
3322
+ updating: false,
3323
+ currentVersion: '0.2.16',
3324
+ latestVersion: undefined,
3325
+ updateAvailable: false,
3326
+ notice: null,
3327
+ error: null
3328
+ });
3329
+
3330
+ const checkUpdates = async () => {
3331
+ setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
3332
+ try {
3333
+ const res = await fetch('/api/dsh-cron/update');
3334
+ const data = await res.json();
3335
+ if (data && data.currentVersion) {
3336
+ setUpdateState(prev => ({
3337
+ ...prev,
3338
+ checking: false,
3339
+ currentVersion: data.currentVersion,
3340
+ latestVersion: data.latestVersion,
3341
+ updateAvailable: Boolean(data.updateAvailable),
3342
+ error: data.latestCheckFailed ? 'Registry check failed' : null
3343
+ }));
3344
+ } else {
3345
+ setUpdateState(prev => ({ ...prev, checking: false, error: data.error || 'Check failed' }));
3346
+ }
3347
+ } catch (err) {
3348
+ setUpdateState(prev => ({ ...prev, checking: false, error: err.message }));
3349
+ }
3350
+ };
3351
+
3352
+ const runUpdate = async () => {
3353
+ setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
3354
+ try {
3355
+ const res = await fetch('/api/dsh-cron/update', {
3356
+ method: 'POST',
3357
+ headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
3358
+ });
3359
+ const data = await res.json();
3360
+ if (data && data.updated) {
3361
+ setUpdateState(prev => ({
3362
+ ...prev,
3363
+ updating: false,
3364
+ updateAvailable: false,
3365
+ currentVersion: data.updatedVersion || data.latestVersion,
3366
+ notice: T_KEY(t, 'updater.success', { version: data.updatedVersion || data.latestVersion })
3367
+ }));
3368
+ } else {
3369
+ setUpdateState(prev => ({
3370
+ ...prev,
3371
+ updating: false,
3372
+ error: data.error || data.message || 'Update failed'
3373
+ }));
3374
+ }
3375
+ } catch (err) {
3376
+ setUpdateState(prev => ({ ...prev, updating: false, error: err.message }));
3377
+ }
3378
+ };
3319
3379
 
3320
3380
  // Settings snapshot status (#102): never render phantom inputs before
3321
3381
  // the snapshot arrives; offer a retry when it is unavailable.
@@ -3521,6 +3581,57 @@ window.__ModuleLoader__.load({
3521
3581
  'data-type': testInfo.success ? 'success' : 'error',
3522
3582
  style: { margin: 0, padding: '8px 12px' }
3523
3583
  }, testInfo.message),
3584
+ React.createElement('div', {
3585
+ className: 'dsh-cron-form-group',
3586
+ style: {
3587
+ background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03))',
3588
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
3589
+ borderRadius: '8px',
3590
+ padding: '14px',
3591
+ marginBottom: '16px'
3592
+ }
3593
+ },
3594
+ React.createElement('div', {
3595
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }
3596
+ },
3597
+ React.createElement('div', { style: { fontWeight: '600', fontSize: '14px' } }, `🚀 ${T_KEY(t, 'updater.title')}`),
3598
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
3599
+ React.createElement('button', {
3600
+ type: 'button',
3601
+ className: 'dsh-cron-btn-secondary',
3602
+ style: { padding: '4px 10px', fontSize: '12px' },
3603
+ disabled: updateState.checking || updateState.updating,
3604
+ onClick: checkUpdates
3605
+ }, updateState.checking ? T_KEY(t, 'updater.checking') : T_KEY(t, 'updater.btnCheck')),
3606
+ updateState.updateAvailable ? React.createElement('button', {
3607
+ type: 'button',
3608
+ className: 'dsh-cron-btn-primary',
3609
+ style: { padding: '4px 10px', fontSize: '12px' },
3610
+ disabled: updateState.updating,
3611
+ onClick: runUpdate
3612
+ }, updateState.updating ? T_KEY(t, 'updater.updating') : T_KEY(t, 'updater.btnUpdate', { version: updateState.latestVersion })) : null
3613
+ )
3614
+ ),
3615
+ React.createElement('div', {
3616
+ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #999)', marginBottom: '8px' }
3617
+ }, T_KEY(t, 'updater.desc')),
3618
+ React.createElement('div', {
3619
+ style: { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '13px' }
3620
+ },
3621
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #aaa)' } },
3622
+ T_KEY(t, 'updater.current', { version: updateState.currentVersion })
3623
+ ),
3624
+ updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-info' }, T_KEY(t, 'updater.checking')) :
3625
+ updateState.updateAvailable ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-warn' }, T_KEY(t, 'updater.available', { version: updateState.latestVersion })) :
3626
+ updateState.currentVersion && !updateState.error ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-ok' }, `✓ ${T_KEY(t, 'updater.upToDate')}`) : null
3627
+ ),
3628
+ updateState.notice ? React.createElement('div', {
3629
+ style: { marginTop: '8px', color: 'var(--dsw-alias-success, #10b981)', fontSize: '12px' }
3630
+ }, updateState.notice) : null,
3631
+ updateState.error ? React.createElement('div', {
3632
+ style: { marginTop: '8px', color: 'var(--dsw-alias-error, #ef4444)', fontSize: '12px' }
3633
+ }, updateState.error) : null
3634
+ ),
3524
3635
  React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
3525
3636
  React.createElement('button', {
3526
3637
  type: 'submit',
@@ -13,7 +13,7 @@
13
13
  import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
14
14
 
15
15
  /** Agent-mediated tasks have a prompt worth revising. */
16
- export const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
16
+ const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
17
17
 
18
18
  export function supportsInspection(task) {
19
19
  const type = String((task && task.type) || 'llm');
@@ -98,10 +98,4 @@ export async function inspectFailure({ task, runInfo, ask, preferredModel, timeo
98
98
  }
99
99
  }
100
100
 
101
- /** Ask helper bound to a context, used by the scheduler. */
102
- export function makeInspectAsk(ctx) {
103
- return async ({ prompt, system, preferredModel, maxTokens, timeoutMs, task }) => {
104
- const target = resolveAskTarget(ctx, task || {}, preferredModel);
105
- return askModel(ctx, { ...target, prompt, system, maxTokens, timeoutMs });
106
- };
107
- }
101
+ // makeInspectAsk removed (#155) — scheduler uses makeAsk(ctx) from lib/llm-ask.js
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { registerPluginUpdater } from './updater.js';
2
+ import { credentialRefStatus } from './secrets.js';
1
3
  import { TaskStore } from './store.js';
2
4
  import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
3
5
  import { SessionRunner } from './runner.js';
@@ -165,6 +167,15 @@ export function sanitizeSettingsPayload(body) {
165
167
  delete payload[key];
166
168
  }
167
169
  }
170
+ for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
171
+ if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
172
+ const refCheck = credentialRefStatus(payload[key]);
173
+ if (!refCheck.ok) {
174
+ console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
175
+ delete payload[key];
176
+ }
177
+ }
178
+ }
168
179
  for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
169
180
  if (key in payload) {
170
181
  console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
@@ -378,6 +389,13 @@ export function apply(ctx, config) {
378
389
  // #48: the recipe hub is the single source of the panel's suggestions.
379
390
  const recommendations = recipeRecommendations();
380
391
 
392
+ // Auto-updater route (#147)
393
+ ctx.effect(() => registerPluginUpdater(ctx, {
394
+ endpoint: '/api/dsh-cron/update',
395
+ packageName: '@goodandready/dsh-cron',
396
+ manifestUrl: new URL('../package.json', import.meta.url),
397
+ }), 'dsh-cron: /api/dsh-cron/update');
398
+
381
399
  // 1. REST API — collection (list/create) + item actions in one prefix
382
400
  // handler per route family; registered WITHOUT the trailing slash (#96)
383
401
  ctx.effect(() => ctx.webServer.register({
package/lib/recipes.js CHANGED
@@ -34,7 +34,7 @@ export const RECIPE_CATEGORIES = [
34
34
  * packages, users and permissions, scheduling and the network edge, power state
35
35
  * and writes outside the working tree.
36
36
  */
37
- export const DESTRUCTIVE_PATTERNS = [
37
+ const DESTRUCTIVE_PATTERNS = [
38
38
  /\brm\b/i,
39
39
  /\bunlink\b/i,
40
40
  /\bshred\b/i,
package/lib/runtimes.js CHANGED
@@ -10,7 +10,7 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
12
12
  export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
13
- export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
13
+ const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
14
14
  export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
15
15
 
16
16
  /** Types that execute code and therefore need the HTTP confirm header. */
@@ -56,7 +56,7 @@ export function buildNodeInvocation(task, cwd) {
56
56
  }
57
57
 
58
58
  /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
59
- export function detectPython(task, cwd) {
59
+ function detectPython(task, cwd) {
60
60
  if (task.pythonPath) return task.pythonPath;
61
61
  const candidates = [];
62
62
  if (process.env.VIRTUAL_ENV) {
package/lib/scheduler.js CHANGED
@@ -6,10 +6,10 @@ import { applySilentRule } from './silent-rule.js';
6
6
  import { inspectFailure } from './failure-inspector.js';
7
7
 
8
8
  /** Task types that talk to a model and can therefore use a fallback model. */
9
- export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
9
+ const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
10
10
 
11
11
  /** Sum two usage records, so a fallback attempt is not lost in accounting. */
12
- export function addUsage(a, b) {
12
+ function addUsage(a, b) {
13
13
  return {
14
14
  inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
15
15
  outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
@@ -15,7 +15,7 @@
15
15
  import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
16
16
 
17
17
  /** Task types whose runs produce output a rule can judge. */
18
- export const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
18
+ const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
19
19
 
20
20
  export function supportsSilentRule(task) {
21
21
  return SILENT_RULE_TASK_TYPES.includes(String(task && task.type ? task.type : 'script'));
@@ -64,6 +64,9 @@ export async function applySilentRule({ task, runInfo, ask, preferredModel, time
64
64
  if (!task || !task.silentRule || String(task.silentRule).trim() === '') {
65
65
  return { skipped: false, reason: '' };
66
66
  }
67
+ if (!supportsSilentRule(task)) {
68
+ return { skipped: false, reason: '', note: 'task type does not support silent rule' };
69
+ }
67
70
  if ((runInfo && runInfo.status) !== 'success') {
68
71
  return { skipped: false, reason: '', note: 'the rule only judges successful runs' };
69
72
  }
package/lib/telegram.js CHANGED
@@ -79,7 +79,7 @@ export function getDshDefaultTelegramCredentials() {
79
79
  /**
80
80
  * Escape markdown special characters for Telegram legacy markdown.
81
81
  */
82
- export function escapeMarkdown(str) {
82
+ function escapeMarkdown(str) {
83
83
  if (!str) return '';
84
84
  return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
85
85
  }
package/lib/updater.js ADDED
@@ -0,0 +1,237 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { rejectCrossOrigin } from './http-utils.js';
8
+
9
+ const UPDATE_TIMEOUT_MS = 10 * 60_000;
10
+ const VERSION_CACHE_MS = 5 * 60_000;
11
+ let latestCache = null;
12
+
13
+ function validProfileName(value) {
14
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
15
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
16
+ }
17
+
18
+ function profileNameFromArgv(argv) {
19
+ for (let index = 2; index < argv.length; index += 1) {
20
+ if (argv[index] === '--profile' || argv[index] === '-p') return argv[index + 1];
21
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
22
+ }
23
+ return argv[2] === 'web' ? 'web' : undefined;
24
+ }
25
+
26
+ function findDshCliEntry() {
27
+ const value = process.argv[1];
28
+ if (value === undefined || value === '') return undefined;
29
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
30
+ if (!existsSync(entry)) return undefined;
31
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
32
+ const manifestPath = resolve(directory, 'package.json');
33
+ if (existsSync(manifestPath)) {
34
+ try {
35
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
36
+ const bin = typeof manifest.bin === 'string'
37
+ ? manifest.bin
38
+ : typeof manifest.bin === 'object' && manifest.bin !== null
39
+ ? manifest.bin.dsh
40
+ : undefined;
41
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
42
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
43
+ } catch {
44
+ // Continue searching parent directories
45
+ }
46
+ }
47
+ const parent = dirname(directory);
48
+ if (parent === directory) return undefined;
49
+ }
50
+ }
51
+
52
+ export function runtime() {
53
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
54
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
55
+ const selected = profileNameFromArgv(process.argv);
56
+ const profileName = validProfileName(selected)
57
+ ? selected
58
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
59
+ const cliEntry = findDshCliEntry();
60
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
61
+ }
62
+
63
+ export function parseSemver(value) {
64
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
65
+ if (match === null) return undefined;
66
+ return {
67
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
68
+ prerelease: match[4]?.split('.') ?? [],
69
+ };
70
+ }
71
+
72
+ function comparePrerelease(left, right) {
73
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
74
+ const length = Math.max(left.length, right.length);
75
+ for (let index = 0; index < length; index += 1) {
76
+ const a = left[index];
77
+ const b = right[index];
78
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
79
+ if (a === b) continue;
80
+ const aNumeric = /^\d+$/.test(a);
81
+ const bNumeric = /^\d+$/.test(b);
82
+ if (aNumeric && bNumeric) {
83
+ const aNumber = BigInt(a);
84
+ const bNumber = BigInt(b);
85
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
86
+ continue;
87
+ }
88
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
89
+ return a > b ? 1 : -1;
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ export function isNewerVersion(currentValue, candidateValue) {
95
+ const current = parseSemver(currentValue);
96
+ const candidate = parseSemver(candidateValue);
97
+ if (current === undefined || candidate === undefined) return false;
98
+ for (let index = 0; index < 3; index += 1) {
99
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
100
+ }
101
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
102
+ }
103
+
104
+ export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
105
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
106
+ return latestCache.version;
107
+ }
108
+ try {
109
+ const cleanReg = registry.replace(/\/$/, '');
110
+ const response = await fetch(`${cleanReg}/${encodeURIComponent(packageName)}/latest`, {
111
+ signal: AbortSignal.timeout(8000),
112
+ headers: { accept: 'application/json' },
113
+ });
114
+ if (!response.ok) return undefined;
115
+ const value = await response.json();
116
+ if (typeof value.version !== 'string' || value.version === '') return undefined;
117
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
118
+ return value.version;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ export async function currentVersion(manifestUrl) {
125
+ const raw = await readFile(manifestUrl, 'utf8');
126
+ const value = JSON.parse(raw);
127
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
128
+ return value.version;
129
+ }
130
+
131
+ export async function getUpdateStatus(options, target = runtime()) {
132
+ const current = await currentVersion(options.manifestUrl);
133
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
134
+ return {
135
+ packageName: options.packageName,
136
+ currentVersion: current,
137
+ ...(latest === undefined ? {} : { latestVersion: latest }),
138
+ latestCheckFailed: latest === undefined,
139
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
140
+ profileName: target.profileName,
141
+ canAutoUpdate: target.cliEntry !== undefined,
142
+ };
143
+ }
144
+
145
+ export async function installExact(target, packageSpec, options = {}) {
146
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
147
+ return new Promise((resolvePromise, reject) => {
148
+ // IMPORTANT: As required by #147, do NOT add minimumReleaseAge bypass
149
+ const args = [
150
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
151
+ packageSpec,
152
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
153
+ ];
154
+ const child = spawn(process.execPath, args, {
155
+ cwd: target.profileDir,
156
+ windowsHide: true,
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ env: { ...process.env, NO_COLOR: '1' },
159
+ });
160
+ let detail = '';
161
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
162
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
163
+ const timer = setTimeout(() => {
164
+ child.kill();
165
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
166
+ }, UPDATE_TIMEOUT_MS);
167
+ child.once('error', error => { clearTimeout(timer); reject(error); });
168
+ child.once('exit', code => {
169
+ clearTimeout(timer);
170
+ if (code === 0) resolvePromise();
171
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
172
+ });
173
+ });
174
+ }
175
+
176
+ export function registerPluginUpdater(ctx, options) {
177
+ let installing = false;
178
+ return ctx.webServer.register({
179
+ kind: 'exact',
180
+ path: options.endpoint || '/api/dsh-cron/update',
181
+ handler: async (req, res) => {
182
+ try {
183
+ const target = runtime();
184
+ if (req.method === 'GET' || req.method === 'HEAD') {
185
+ const payload = await getUpdateStatus(options, target);
186
+ res.writeHead(200, {
187
+ 'content-type': 'application/json; charset=utf-8',
188
+ 'cache-control': 'no-store',
189
+ });
190
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(payload));
191
+ return;
192
+ }
193
+ if (req.method !== 'POST') {
194
+ res.writeHead(405, { allow: 'GET, HEAD, POST' });
195
+ res.end();
196
+ return;
197
+ }
198
+ if (rejectCrossOrigin(req, res)) {
199
+ return;
200
+ }
201
+ if (installing) {
202
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' });
203
+ res.end(JSON.stringify({ ok: false, error: 'This plugin is already updating.' }));
204
+ return;
205
+ }
206
+ installing = true;
207
+ try {
208
+ const before = await getUpdateStatus(options, target);
209
+ if (before.latestVersion === undefined) {
210
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
211
+ res.end(JSON.stringify({ ok: false, error: 'The latest version is temporarily unavailable.' }));
212
+ return;
213
+ }
214
+ if (!before.updateAvailable) {
215
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
216
+ res.end(JSON.stringify({ ...before, updated: false, message: 'Already up to date.' }));
217
+ return;
218
+ }
219
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
220
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
221
+ res.end(JSON.stringify({
222
+ ...before,
223
+ updated: true,
224
+ updatedVersion: before.latestVersion,
225
+ restartRequired: true,
226
+ }));
227
+ } finally {
228
+ installing = false;
229
+ }
230
+ } catch (error) {
231
+ (ctx.logger || console).warn?.(`[dsh-cron] plugin updater failed: ${String(error)}`);
232
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
233
+ res.end(JSON.stringify({ ok: false, error: 'Plugin update failed; see server logs.' }));
234
+ }
235
+ },
236
+ });
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",