@goodandready/dsh-cron 0.2.3 → 0.2.4

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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
3
+ *
4
+ * Every builder is a pure function returning the process invocation
5
+ * ({ file, args, env }) or the HTTP request description, so the whole matrix
6
+ * is unit-testable without spawning anything.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
13
+ export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
14
+ export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
15
+
16
+ /** Types that execute code and therefore need the HTTP confirm header. */
17
+ export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
18
+
19
+ export function normalizeTaskType(type) {
20
+ return TASK_TYPES.includes(type) ? type : 'llm';
21
+ }
22
+
23
+ /**
24
+ * Split a leading script path from trailing CLI arguments.
25
+ * Quoted segments are preserved as single arguments.
26
+ */
27
+ export function splitCommandLine(text) {
28
+ const trimmed = String(text || '').trim();
29
+ if (!trimmed) return { path: '', args: [] };
30
+ const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
31
+ const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
32
+ return { path: clean[0] || '', args: clean.slice(1) };
33
+ }
34
+
35
+ function looksLikeScriptFile(token, extensions) {
36
+ if (!token) return false;
37
+ const ext = path.extname(token).toLowerCase();
38
+ if (extensions.includes(ext)) return true;
39
+ try {
40
+ return fs.existsSync(token) && fs.statSync(token).isFile();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
47
+ export function buildNodeInvocation(task, cwd) {
48
+ const { path: filePath, args } = splitCommandLine(task.prompt);
49
+ if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
50
+ return { file: process.execPath, args: [filePath, ...args] };
51
+ }
52
+ const code = String(task.prompt || '');
53
+ const isEsm = /\bimport\s|\bexport\s/.test(code);
54
+ const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
55
+ return { file: task.nodePath || process.execPath, args: nodeArgs };
56
+ }
57
+
58
+ /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
59
+ export function detectPython(task, cwd) {
60
+ if (task.pythonPath) return task.pythonPath;
61
+ const candidates = [];
62
+ if (process.env.VIRTUAL_ENV) {
63
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
64
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
65
+ }
66
+ const base = cwd || process.cwd();
67
+ candidates.push(path.join(base, '.venv', 'bin', 'python'));
68
+ candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
69
+ candidates.push(path.join(base, 'venv', 'bin', 'python'));
70
+ for (const candidate of candidates) {
71
+ try {
72
+ if (fs.existsSync(candidate)) return candidate;
73
+ } catch {}
74
+ }
75
+ return process.platform === 'win32' ? 'python' : 'python3';
76
+ }
77
+
78
+ /** #4: resolve a Python invocation (inline code or a .py file). */
79
+ export function buildPythonInvocation(task, cwd) {
80
+ const interpreter = detectPython(task, cwd);
81
+ const { path: filePath, args } = splitCommandLine(task.prompt);
82
+ if (looksLikeScriptFile(filePath, ['.py'])) {
83
+ return { file: interpreter, args: [filePath, ...args] };
84
+ }
85
+ return { file: interpreter, args: ['-c', String(task.prompt || '')] };
86
+ }
87
+
88
+ /** #7: build the HTTP trigger request description. */
89
+ export function buildHttpRequest(task) {
90
+ const raw = String(task.httpUrl || task.prompt || '').trim();
91
+ if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
92
+ let url;
93
+ try {
94
+ url = new URL(raw);
95
+ } catch {
96
+ throw new Error(`Invalid HTTP URL: ${raw}`);
97
+ }
98
+ let headers = {};
99
+ if (task.httpHeaders) {
100
+ if (typeof task.httpHeaders === 'object') {
101
+ headers = { ...task.httpHeaders };
102
+ } else {
103
+ try {
104
+ headers = JSON.parse(String(task.httpHeaders));
105
+ } catch {
106
+ throw new Error('httpHeaders must be a JSON object');
107
+ }
108
+ }
109
+ }
110
+ const method = String(task.httpMethod || 'GET').toUpperCase();
111
+ const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
112
+ return { url: url.toString(), method, headers, body };
113
+ }
114
+
115
+ /** #9: build a remote SSH invocation. */
116
+ export function buildSshInvocation(task) {
117
+ const target = String(task.sshTarget || '').trim();
118
+ if (!target) throw new Error('SSH task requires sshTarget (user@host)');
119
+ const args = [];
120
+ if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
121
+ if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
122
+ args.push(target, String(task.prompt || 'true'));
123
+ return { file: 'ssh', args };
124
+ }
125
+
126
+ /** #8: build a Docker run invocation. */
127
+ export function buildDockerInvocation(task, env) {
128
+ const image = String(task.dockerImage || '').trim();
129
+ if (!image) throw new Error('Docker task requires dockerImage');
130
+ const args = ['run', '--rm'];
131
+ for (const [key, value] of Object.entries(env || {})) {
132
+ if (key === 'PATH' || key === 'HOME') continue; // container owns those
133
+ args.push('-e', `${key}=${value}`);
134
+ }
135
+ if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
136
+ args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
137
+ return { file: 'docker', args };
138
+ }
139
+
140
+ /** #38: merge task env vars over the base environment. */
141
+ export function buildEnv(task, baseEnv) {
142
+ const env = { ...(baseEnv || process.env) };
143
+ if (task && task.env && typeof task.env === 'object') {
144
+ for (const [key, value] of Object.entries(task.env)) {
145
+ if (value === null || value === undefined) delete env[key];
146
+ else env[key] = String(value);
147
+ }
148
+ }
149
+ return env;
150
+ }
151
+
152
+ /**
153
+ * Dispatch an external (non-LLM) runtime to its invocation.
154
+ * Returns { file, args } plus an optional `http` descriptor for the fetch path.
155
+ */
156
+ export function buildInvocation(task, cwd, baseEnv) {
157
+ const type = normalizeTaskType(task.type);
158
+ const env = buildEnv(task, baseEnv);
159
+ switch (type) {
160
+ case 'node':
161
+ return { type, env, ...buildNodeInvocation(task, cwd) };
162
+ case 'python':
163
+ return { type, env, ...buildPythonInvocation(task, cwd) };
164
+ case 'http':
165
+ return { type, env, http: buildHttpRequest(task) };
166
+ case 'ssh':
167
+ return { type, env, ...buildSshInvocation(task) };
168
+ case 'docker':
169
+ return { type, env, ...buildDockerInvocation(task, env) };
170
+ case 'script':
171
+ default:
172
+ return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
173
+ }
174
+ }
package/lib/scheduler.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import { Cron } from 'croner';
2
- import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
3
- import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
4
2
 
5
3
  function pad(v) {
6
4
  return String(v).padStart(2, '0');
@@ -142,6 +140,8 @@ export class TaskScheduler {
142
140
  this.executeFn = executeFn;
143
141
  this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
144
142
  this.defaultTimezone = options.defaultTimezone || ''; // #13
143
+ this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
144
+ this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
145
145
  this.jobs = new Map(); // taskId -> Cron instance
146
146
  this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
147
147
  this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
@@ -266,9 +266,11 @@ export class TaskScheduler {
266
266
  this.store.set(task);
267
267
 
268
268
  // If target is in the past, or when delay triggers:
269
- const timer = setTimeout(async () => {
269
+ const timer = setTimeout(() => {
270
270
  this.timers.delete(task.id);
271
- await this.runTask(task.id);
271
+ this.runTask(task.id).catch((err) => {
272
+ console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
273
+ });
272
274
  }, delay);
273
275
 
274
276
  this.timers.set(task.id, timer);
@@ -276,7 +278,11 @@ export class TaskScheduler {
276
278
  }
277
279
 
278
280
  // Handle Recurring Cron job
279
- const job = new Cron(parsed.cronPattern, { protect: true, ...(timezone ? { timezone } : {}) }, async () => {
281
+ const job = new Cron(parsed.cronPattern, {
282
+ protect: true,
283
+ catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
284
+ ...(timezone ? { timezone } : {}),
285
+ }, async () => {
280
286
  await this.runTask(task.id);
281
287
  });
282
288
 
@@ -388,7 +394,11 @@ export class TaskScheduler {
388
394
  costUsd,
389
395
  sessionId,
390
396
  };
391
- this.store.recordRun(taskId, runInfo);
397
+ try {
398
+ this.store.recordRun(taskId, runInfo);
399
+ } catch (recordErr) {
400
+ console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
401
+ }
392
402
 
393
403
  // Clean up running map
394
404
  const finishedRun = this.running.get(taskId);
@@ -398,58 +408,48 @@ export class TaskScheduler {
398
408
 
399
409
  const queuedCount = finishedRun?.queueCount || 0;
400
410
 
401
- // 1. Telegram notification delivery
402
- try {
403
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
404
- if (shouldNotifyTask(task, runInfo, settings)) {
405
- const botToken = settings.botToken;
406
- const chatId = settings.chatId;
407
- if (botToken && chatId) {
408
- const text = formatTaskTelegramMessage(task, runInfo);
409
- sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
410
- console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
411
- });
412
- }
413
- }
414
- } catch (notifyErr) {
415
- console.error('[dsh-cron] notification error:', notifyErr.message);
416
- }
411
+ await this.deliverNotifications(task, runInfo);
412
+ this.finishRun(task, taskId, status, queuedCount);
413
+ }
414
+ }
417
415
 
418
- // 2. Kanban card creation
419
- try {
420
- if (shouldCreateKanbanCard(task, runInfo)) {
421
- const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
422
- const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
423
- const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
424
- const cardBody = [
425
- `**Cron task:** ${task.title} (\`${task.id}\`)`,
426
- `**Schedule:** ${task.scheduleText || task.schedule}`,
427
- `**Status:** ${runInfo.status}`,
428
- `**Started at:** ${new Date(runInfo.at).toISOString()}`,
429
- runInfo.error ? `\n**Error:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
430
- runInfo.output ? `\n**Log / Output:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
431
- ].filter(Boolean).join('\n\n');
432
-
433
- createKanbanCard({
434
- title: cardTitle,
435
- body: cardBody,
436
- board: 'main',
437
- column: isErr ? 'backlog' : 'done',
438
- labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
439
- kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
440
- }).then((res) => {
441
- if (res.success) {
442
- console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
443
- }
444
- }).catch((kErr) => {
445
- console.error('[dsh-cron] kanban card error:', kErr.message);
446
- });
416
+ /**
417
+ * Deliver a finished run to every configured channel (#26).
418
+ * Never throws: a broken or slow channel is reported, not propagated, so the
419
+ * cron callback (and therefore the next tick) is never blocked by delivery.
420
+ */
421
+ async deliverNotifications(task, runInfo) {
422
+ try {
423
+ let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
424
+ // #51: resolve delivery credentials through the credentials service
425
+ // (credential reference → legacy token → gateway file → env).
426
+ if (this.resolveSecrets) {
427
+ try {
428
+ settings = { ...settings, ...(await this.resolveSecrets(settings)) };
429
+ } catch (secErr) {
430
+ console.warn('[dsh-cron] credential resolution failed:', secErr.message);
447
431
  }
448
- } catch (kanbanErr) {
449
- console.error('[dsh-cron] kanban error:', kanbanErr.message);
450
432
  }
433
+ if (typeof this.deliver !== 'function') return;
434
+ const result = await this.deliver(task, runInfo, settings);
435
+ if (result && Array.isArray(result.delivered) && result.delivered.length) {
436
+ console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
437
+ }
438
+ if (result && Array.isArray(result.failures) && result.failures.length) {
439
+ console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
440
+ }
441
+ } catch (notifyErr) {
442
+ console.error('[dsh-cron] notification error:', notifyErr.message);
443
+ }
444
+ }
451
445
 
452
- // 3. Reschedule or mark completed for one-shot
446
+ /**
447
+ * Post-run bookkeeping: one-shot completion, next-run timestamp, retry
448
+ * backoff and draining a queued run. Each part is guarded so a store error
449
+ * cannot reject the cron callback.
450
+ */
451
+ finishRun(task, taskId, status, queuedCount) {
452
+ try {
453
453
  if (task.oneShot) {
454
454
  task.status = 'completed';
455
455
  task.nextRunAt = null;
@@ -462,8 +462,12 @@ export class TaskScheduler {
462
462
  this.store.set(task);
463
463
  }
464
464
  }
465
+ } catch (err) {
466
+ console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
467
+ }
465
468
 
466
- // 4. Retry with exponential backoff on failure (#18)
469
+ try {
470
+ // Retry with exponential backoff on failure (#18)
467
471
  const failed = status === 'error' || status === 'timeout';
468
472
  const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
469
473
  if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
@@ -481,12 +485,14 @@ export class TaskScheduler {
481
485
  task.attempts = 0;
482
486
  this.store.set(task);
483
487
  }
488
+ } catch (err) {
489
+ console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
490
+ }
484
491
 
485
- if (queuedCount > 0) {
486
- setImmediate(() => {
487
- this.runTask(taskId);
488
- });
489
- }
492
+ if (queuedCount > 0) {
493
+ setImmediate(() => {
494
+ this.runTask(taskId);
495
+ });
490
496
  }
491
497
  }
492
498
 
package/lib/secrets.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Credential handling for dsh-cron (#51).
3
+ *
4
+ * Settings store only the NAME of a credential (an environment-variable-style
5
+ * reference); the value is resolved at send time through the DSH credentials
6
+ * service, with an environment fallback. This mirrors the pattern already
7
+ * used by dsh-clinebot and dsh-agentrouter.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import { getDshSettingsPath } from './telegram.js';
12
+
13
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ const TOKEN_LIKE = /^[A-Za-z0-9:_-]{24,}$/;
15
+
16
+ export function isCredentialRefName(value) {
17
+ return REF_PATTERN.test(String(value || '').trim());
18
+ }
19
+
20
+ /** Heuristic: a pasted secret instead of a credential name. */
21
+ export function looksLikeSecret(value) {
22
+ const s = String(value || '').trim();
23
+ if (!s || isCredentialRefName(s)) return false;
24
+ return TOKEN_LIKE.test(s);
25
+ }
26
+
27
+ /**
28
+ * Validate a credential-reference field. Returns { ok, name } or
29
+ * { ok: false, error } with actionable text for the settings card.
30
+ */
31
+ export function credentialRefStatus(value) {
32
+ const name = String(value || '').trim();
33
+ if (!name) return { ok: true, name: '' };
34
+ if (looksLikeSecret(name)) {
35
+ return {
36
+ ok: false,
37
+ error: 'That looks like a secret value. Store it in DSH credentials and enter only the credential name (for example CRON_TELEGRAM_BOT_TOKEN).',
38
+ };
39
+ }
40
+ if (!isCredentialRefName(name)) {
41
+ return { ok: false, error: 'Credential name must look like an environment variable name, e.g. CRON_TELEGRAM_BOT_TOKEN.' };
42
+ }
43
+ return { ok: true, name };
44
+ }
45
+
46
+ /** Resolve a credential through the harness service, then the environment. */
47
+ export async function resolveCredentialValue(ctx, refName) {
48
+ const name = String(refName || '').trim();
49
+ if (!name) return null;
50
+ try {
51
+ const creds = (ctx && typeof ctx.get === 'function' ? ctx.get('credentials') : null)
52
+ || (ctx && ctx.credentials);
53
+ if (creds && typeof creds.resolve === 'function') {
54
+ // Prefer the harness ref factory; fall back to the plain name when the
55
+ // package is not installed (keeps tests and trimmed builds working).
56
+ let ref = name;
57
+ try {
58
+ const mod = await import('@deepseek-ai/dsh-credentials');
59
+ if (mod && typeof mod.credentialRef === 'function') ref = mod.credentialRef(name);
60
+ } catch {}
61
+ const hit = await creds.resolve(ref);
62
+ if (hit && hit.value) return String(hit.value);
63
+ }
64
+ } catch {
65
+ // credentials service unavailable — fall back to the environment
66
+ }
67
+ const fromEnv = process.env[name];
68
+ return fromEnv ? String(fromEnv) : null;
69
+ }
70
+
71
+ /** Read the best-effort Telegram defaults from the messenger-gateway file. */
72
+ function readGatewayToken() {
73
+ try {
74
+ const p = getDshSettingsPath();
75
+ if (!fs.existsSync(p)) return null;
76
+ const text = fs.readFileSync(p, 'utf-8');
77
+ const lines = text.split('\n');
78
+ let inside = false;
79
+ for (const line of lines) {
80
+ if (/^[^\s#]/.test(line)) inside = line.startsWith('dsh-messenger-gateway:');
81
+ if (!inside) continue;
82
+ const hit = /^[ \t]+botToken:[ \t]*(.+?)[ \t]*$/.exec(line);
83
+ if (hit) return hit[1].replace(/^['"]|['"]$/g, '').trim() || null;
84
+ }
85
+ } catch {}
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Resolve the Telegram delivery secrets for a run.
91
+ * Precedence: credential reference → legacy stored token → messenger-gateway
92
+ * settings file → environment variable.
93
+ */
94
+ export async function resolveTelegramSecrets(ctx, settings = {}) {
95
+ const out = { botToken: '', chatId: '', tokenSource: 'none' };
96
+
97
+ const fromRef = await resolveCredentialValue(ctx, settings.botTokenRef);
98
+ if (fromRef) {
99
+ out.botToken = fromRef;
100
+ out.tokenSource = 'credential';
101
+ }
102
+ if (!out.botToken && settings.botToken) {
103
+ out.botToken = String(settings.botToken);
104
+ out.tokenSource = 'legacy-settings';
105
+ }
106
+ if (!out.botToken) {
107
+ const gateway = readGatewayToken();
108
+ if (gateway) {
109
+ out.botToken = gateway;
110
+ out.tokenSource = 'messenger-gateway';
111
+ }
112
+ }
113
+ if (!out.botToken) {
114
+ const fromEnv = await resolveCredentialValue(ctx, 'CRON_TELEGRAM_BOT_TOKEN');
115
+ if (fromEnv) {
116
+ out.botToken = fromEnv;
117
+ out.tokenSource = 'env';
118
+ }
119
+ }
120
+
121
+ out.chatId = String(settings.chatId || '').trim();
122
+ return out;
123
+ }
package/lib/store.js CHANGED
@@ -2,11 +2,31 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { getDshDefaultTelegramCredentials } from './telegram.js';
4
4
 
5
+ /**
6
+ * Where plugin data lives.
7
+ *
8
+ * DSH_DATA_DIR wins when the harness sets it. Otherwise the profile home
9
+ * (DSH_HOME) is authoritative: falling back to ~/.dsh would make an isolated
10
+ * profile — a test contour, a second profile — write its tasks into another
11
+ * home's data directory, which is how the MiniPC test cycle leaked a task into
12
+ * the non-test profile.
13
+ */
5
14
  export function getDefaultStorePath() {
6
- const base = process.env.DSH_DATA_DIR || path.join(process.env.HOME || '.', '.dsh', 'data');
15
+ const base = process.env.DSH_DATA_DIR
16
+ || (process.env.DSH_HOME
17
+ ? path.join(process.env.DSH_HOME, 'data')
18
+ : path.join(process.env.HOME || '.', '.dsh', 'data'));
7
19
  return path.join(base, 'cron', 'tasks.json');
8
20
  }
9
21
 
22
+ /** Mask a secret for transport to the browser, keeping a readable hint. */
23
+ function maskSecretValue(value) {
24
+ const s = String(value == null ? '' : value);
25
+ if (!s) return '';
26
+ if (s.length <= 8) return '••••••••';
27
+ return s.slice(0, 4) + '••••••••' + s.slice(-3);
28
+ }
29
+
10
30
  export class TaskStore {
11
31
  constructor(filePath) {
12
32
  this.filePath = filePath || getDefaultStorePath();
@@ -70,14 +90,15 @@ export class TaskStore {
70
90
 
71
91
  getSettings() {
72
92
  const defaults = getDshDefaultTelegramCredentials();
73
- return {
74
- botToken: this.settings.botToken !== undefined ? this.settings.botToken : (defaults.botToken || ''),
75
- chatId: this.settings.chatId !== undefined ? this.settings.chatId : (defaults.chatId || ''),
76
- notifyTelegram: Boolean(this.settings.notifyTelegram),
77
- onlyOnFailure: Boolean(this.settings.onlyOnFailure),
78
- hasDefaultCredentials: Boolean(defaults.botToken && defaults.chatId),
79
- kanbanBaseUrl: this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
80
- };
93
+ const base = { ...this.settings };
94
+ base.botToken = base.botToken !== undefined ? base.botToken : (defaults.botToken || '');
95
+ base.botTokenRef = base.botTokenRef || '';
96
+ base.chatId = base.chatId !== undefined ? base.chatId : (defaults.chatId || '');
97
+ base.notifyTelegram = Boolean(base.notifyTelegram);
98
+ base.onlyOnFailure = Boolean(base.onlyOnFailure);
99
+ base.kanbanBaseUrl = base.kanbanBaseUrl || 'http://127.0.0.1:3000';
100
+ base.hasDefaultCredentials = Boolean(defaults.botToken && defaults.chatId);
101
+ return base;
81
102
  }
82
103
 
83
104
  getClientSettings() {
@@ -85,38 +106,87 @@ export class TaskStore {
85
106
  const isCustom = Boolean(this.settings.botToken);
86
107
  const hasToken = Boolean(raw.botToken);
87
108
  const masked = hasToken ? (raw.botToken.length > 8 ? raw.botToken.slice(0, 4) + '••••••••' + raw.botToken.slice(-3) : '••••••••') : '';
88
- return {
109
+ const { botToken, ...rest } = raw;
110
+ const out = {
111
+ ...rest,
89
112
  botToken: masked,
90
113
  botTokenMasked: masked,
91
114
  hasBotToken: hasToken,
92
115
  hasCustomBotToken: isCustom,
93
- chatId: raw.chatId,
94
- notifyTelegram: raw.notifyTelegram,
95
- onlyOnFailure: raw.onlyOnFailure,
96
- hasDefaultCredentials: raw.hasDefaultCredentials,
97
- kanbanBaseUrl: raw.kanbanBaseUrl,
98
116
  };
117
+ // Webhook URLs and the Bark device key carry an embedded secret, so they
118
+ // are never sent to the browser in clear text either.
119
+ for (const key of TaskStore.MASKED_SETTING_KEYS) {
120
+ if (key === 'botToken') continue;
121
+ out[key] = maskSecretValue(raw[key]);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Raw secret values that must not be stored in plugin settings; the
128
+ * corresponding *Ref credential names are the supported way to configure
129
+ * these channels.
130
+ */
131
+ static get FORBIDDEN_SETTING_KEYS() {
132
+ return ['smtpPassword', 'giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
133
+ }
134
+
135
+ /**
136
+ * Settings that hold a secret and are therefore masked on read: tokens,
137
+ * passwords, webhook URLs (the URL embeds the auth token) and the Bark key.
138
+ * These are stored in the plugin settings file; the credential-reference
139
+ * fields (botTokenRef, smtpPasswordRef, …) take precedence over them.
140
+ */
141
+ static get MASKED_SETTING_KEYS() {
142
+ return ['botToken', 'smtpPassword', 'discordWebhookUrl', 'slackWebhookUrl', 'barkKey'];
99
143
  }
100
144
 
101
145
  saveSettings(newSettings = {}) {
146
+ const patch = { ...newSettings };
147
+
148
+ for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
149
+ if (key in patch) {
150
+ console.warn(`[dsh-cron] refusing to store raw secret "${key}" in settings — use a credential reference instead`);
151
+ delete patch[key];
152
+ }
153
+ }
154
+
102
155
  let botTokenToSave = this.settings.botToken || '';
103
- if (newSettings.botToken !== undefined) {
104
- const val = String(newSettings.botToken).trim();
156
+ if (patch.botToken !== undefined) {
157
+ const val = String(patch.botToken).trim();
105
158
  // If client sends masked token or empty string while custom token existed, don't overwrite with dots
106
159
  if (val && !val.includes('••') && !val.includes('****')) {
107
160
  botTokenToSave = val;
108
161
  } else if (val === '') {
109
162
  botTokenToSave = '';
110
163
  }
164
+ delete patch.botToken;
165
+ }
166
+
167
+ // A masked value echoed back by the UI must never overwrite the real one.
168
+ for (const key of TaskStore.MASKED_SETTING_KEYS) {
169
+ if (key === 'botToken') continue;
170
+ const val = patch[key];
171
+ if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
172
+ delete patch[key];
173
+ }
174
+ }
175
+
176
+ for (const [key, value] of Object.entries(patch)) {
177
+ if (value === undefined) delete patch[key];
178
+ else if (typeof value === 'string') patch[key] = value.trim();
111
179
  }
112
180
 
113
181
  this.settings = {
114
182
  ...this.settings,
183
+ ...patch,
115
184
  botToken: botTokenToSave,
116
- chatId: newSettings.chatId !== undefined ? String(newSettings.chatId).trim() : (this.settings.chatId || ''),
117
- notifyTelegram: newSettings.notifyTelegram !== undefined ? Boolean(newSettings.notifyTelegram) : Boolean(this.settings.notifyTelegram),
118
- onlyOnFailure: newSettings.onlyOnFailure !== undefined ? Boolean(newSettings.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
119
- kanbanBaseUrl: newSettings.kanbanBaseUrl !== undefined ? String(newSettings.kanbanBaseUrl).trim() : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
185
+ kanbanBaseUrl: patch.kanbanBaseUrl !== undefined
186
+ ? (patch.kanbanBaseUrl || 'http://127.0.0.1:3000')
187
+ : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
188
+ notifyTelegram: patch.notifyTelegram !== undefined ? Boolean(patch.notifyTelegram) : Boolean(this.settings.notifyTelegram),
189
+ onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
120
190
  };
121
191
  this.save();
122
192
  return this.getSettings();