@goodandready/dsh-cron 0.2.3 → 0.2.5
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 +69 -15
- package/docs/README.ru.md +69 -14
- package/docs/README.zh.md +74 -14
- package/docs/design/DESIGN.md +18 -8
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +412 -0
- package/lib/client.js +1134 -30
- package/lib/index.js +311 -38
- package/lib/integrations.js +3 -1
- package/lib/runner.js +185 -28
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +66 -60
- package/lib/secrets.js +123 -0
- package/lib/store.js +91 -21
- package/lib/task-transfer.js +226 -0
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 ['giteaToken', 'ntfyToken', 'pushplusToken', 'discordToken', 'slackToken'];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Settings that hold a secret and are therefore masked on read: tokens and
|
|
137
|
+
* webhook URLs (the URL embeds the auth token), plus the Bark device key.
|
|
138
|
+
* These are stored in the plugin settings file; the credential-reference
|
|
139
|
+
* fields (botTokenRef, ntfyTokenRef, …) take precedence over them.
|
|
140
|
+
*/
|
|
141
|
+
static get MASKED_SETTING_KEYS() {
|
|
142
|
+
return ['botToken', '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 (
|
|
104
|
-
const val = String(
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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();
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task transfer helpers: duplication, export and import (#41, #42).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over plain task objects with no I/O, so the whole
|
|
5
|
+
* configuration-transfer contract is unit-testable and the HTTP layer stays a
|
|
6
|
+
* thin adapter. Kept out of index.js both for cohesion and to keep that file
|
|
7
|
+
* from growing further (see issue #97).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { normalizeTaskType } from './runtimes.js';
|
|
12
|
+
import { CHANNEL_IDS } from './channels.js';
|
|
13
|
+
|
|
14
|
+
/** Task fields a client may patch; the single source of truth below. */
|
|
15
|
+
export const PATCHABLE_TASK_FIELDS = [
|
|
16
|
+
'title',
|
|
17
|
+
'schedule',
|
|
18
|
+
'scheduleText',
|
|
19
|
+
'prompt',
|
|
20
|
+
'type',
|
|
21
|
+
'delivery',
|
|
22
|
+
'provider',
|
|
23
|
+
'model',
|
|
24
|
+
'timezone',
|
|
25
|
+
'misfirePolicy',
|
|
26
|
+
'maxRetries',
|
|
27
|
+
'retryBackoffMs',
|
|
28
|
+
'permissionPreset',
|
|
29
|
+
'env',
|
|
30
|
+
'cwd',
|
|
31
|
+
'workspaceId',
|
|
32
|
+
'worktree',
|
|
33
|
+
'keepWorktree',
|
|
34
|
+
'httpMethod',
|
|
35
|
+
'httpUrl',
|
|
36
|
+
'httpHeaders',
|
|
37
|
+
'httpBody',
|
|
38
|
+
'sshTarget',
|
|
39
|
+
'sshPort',
|
|
40
|
+
'sshKeyPath',
|
|
41
|
+
'sshProfileId',
|
|
42
|
+
'dockerImage',
|
|
43
|
+
'pythonPath',
|
|
44
|
+
'nodePath',
|
|
45
|
+
'skillName',
|
|
46
|
+
'workflowName',
|
|
47
|
+
'notifyTelegram',
|
|
48
|
+
'onlyOnFailure',
|
|
49
|
+
'timeoutSeconds',
|
|
50
|
+
'overlapPolicy',
|
|
51
|
+
'kanbanMode',
|
|
52
|
+
'channels',
|
|
53
|
+
'template',
|
|
54
|
+
'status',
|
|
55
|
+
'oneShot',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Fields a duplicate inherits; anything else is runtime state, not config. */
|
|
59
|
+
export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status');
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Task ids that would shadow the collection routes under /dsh-cron/tasks.
|
|
63
|
+
* A task may not use them, otherwise it could never be fetched, paused or
|
|
64
|
+
* deleted again (#42 review finding).
|
|
65
|
+
*/
|
|
66
|
+
export const RESERVED_TASK_IDS = ['export', 'import'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Task-level fields that can carry a secret the user typed in by hand
|
|
70
|
+
* (`env` values, authorization headers). The export includes them, so the UI
|
|
71
|
+
* and docs must say so instead of promising a secret-free file.
|
|
72
|
+
*/
|
|
73
|
+
export const EXPORT_SECRET_BEARING_FIELDS = ['env', 'httpHeaders', 'httpBody'];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate runtime-specific task configuration. Returns an error string or
|
|
77
|
+
* null when the payload is usable for the given type.
|
|
78
|
+
*/
|
|
79
|
+
export function validateTaskType(type, payload) {
|
|
80
|
+
const t = normalizeTaskType(type);
|
|
81
|
+
if (t === 'http') {
|
|
82
|
+
const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
|
|
83
|
+
if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
|
|
84
|
+
try {
|
|
85
|
+
// eslint-disable-next-line no-new
|
|
86
|
+
new URL(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return `Invalid HTTP URL: ${raw}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (t === 'ssh') {
|
|
92
|
+
const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
|
|
93
|
+
const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
|
|
94
|
+
if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
|
|
95
|
+
}
|
|
96
|
+
if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
|
|
97
|
+
return 'Docker tasks require dockerImage';
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a paused copy of a task (#41).
|
|
104
|
+
*
|
|
105
|
+
* Configuration is copied, execution state is not: the copy starts paused with
|
|
106
|
+
* no history, no token/cost totals, no last-run info and no retry counter, so a
|
|
107
|
+
* duplicate can be reviewed before it ever runs. A duplicated one-shot keeps
|
|
108
|
+
* its original schedule string and stays inert until the user resumes it.
|
|
109
|
+
*/
|
|
110
|
+
export function buildDuplicateTask(source, { id } = {}) {
|
|
111
|
+
const copy = {
|
|
112
|
+
id: id || randomUUID(),
|
|
113
|
+
title: `${source.title || 'Task'} (copy)`,
|
|
114
|
+
status: 'paused',
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
updatedAt: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
119
|
+
if (key === 'title') continue;
|
|
120
|
+
if (source[key] !== undefined) copy[key] = Array.isArray(source[key]) ? [...source[key]] : source[key];
|
|
121
|
+
}
|
|
122
|
+
return copy;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------- export / import (#42)
|
|
126
|
+
|
|
127
|
+
/** Document kind marker so a random JSON file is not mistaken for an export. */
|
|
128
|
+
export const TASK_EXPORT_KIND = 'dsh-cron-tasks';
|
|
129
|
+
export const TASK_EXPORT_VERSION = 1;
|
|
130
|
+
|
|
131
|
+
/** Fields an exported task carries: identity + configuration, never run state. */
|
|
132
|
+
export const EXPORT_TASK_FIELDS = ['id', 'title', ...DUPLICATE_TASK_FIELDS.filter((k) => k !== 'title')];
|
|
133
|
+
|
|
134
|
+
/** Import strategies: add (new ids), replace (overwrite by id), skip (leave existing). */
|
|
135
|
+
export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Serialise tasks for transfer/backup. Run state (history, token and cost
|
|
139
|
+
* totals, last-run info) is deliberately excluded — the file is a
|
|
140
|
+
* configuration artefact. Channels reference credentials by name, but task-level
|
|
141
|
+
* `env` and headers can contain hand-typed secrets, so the caller must not
|
|
142
|
+
* claim the file is secret-free.
|
|
143
|
+
*/
|
|
144
|
+
export function buildTaskExport(tasks, { now = Date.now() } = {}) {
|
|
145
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
146
|
+
return {
|
|
147
|
+
kind: TASK_EXPORT_KIND,
|
|
148
|
+
version: TASK_EXPORT_VERSION,
|
|
149
|
+
exportedAt: new Date(now).toISOString(),
|
|
150
|
+
tasks: list.map((task) => {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const key of EXPORT_TASK_FIELDS) {
|
|
153
|
+
if (task[key] === undefined || key === 'status') continue;
|
|
154
|
+
out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate an import document and return sanitised tasks.
|
|
163
|
+
*
|
|
164
|
+
* An import file is external input, so it is treated as untrusted: only
|
|
165
|
+
* whitelisted configuration fields survive, the file's `status` is ignored
|
|
166
|
+
* (imported tasks always start paused, matching the documented behaviour), and
|
|
167
|
+
* a document that would shadow a collection route id is refused. A malformed
|
|
168
|
+
* file is rejected as a whole so nothing is written.
|
|
169
|
+
*/
|
|
170
|
+
export function validateImportDocument(doc) {
|
|
171
|
+
if (!doc || typeof doc !== 'object') return { ok: false, error: 'Import file is not a JSON object' };
|
|
172
|
+
if (doc.kind !== TASK_EXPORT_KIND) return { ok: false, error: `Not a ${TASK_EXPORT_KIND} export (missing kind marker)` };
|
|
173
|
+
if (Number(doc.version) > TASK_EXPORT_VERSION) {
|
|
174
|
+
return { ok: false, error: `Export version ${doc.version} is newer than this plugin understands (${TASK_EXPORT_VERSION})` };
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(doc.tasks)) return { ok: false, error: 'Export has no tasks array' };
|
|
177
|
+
const tasks = [];
|
|
178
|
+
for (const [index, raw] of doc.tasks.entries()) {
|
|
179
|
+
if (!raw || typeof raw !== 'object') return { ok: false, error: `Task #${index + 1} is not an object` };
|
|
180
|
+
if (!raw.title || !String(raw.title).trim()) return { ok: false, error: `Task #${index + 1} has no title` };
|
|
181
|
+
if (!raw.schedule || !String(raw.schedule).trim()) return { ok: false, error: `Task #${index + 1} has no schedule` };
|
|
182
|
+
if (!raw.prompt || !String(raw.prompt).trim()) return { ok: false, error: `Task #${index + 1} has no prompt` };
|
|
183
|
+
const id = raw.id === undefined ? undefined : String(raw.id);
|
|
184
|
+
if (id && RESERVED_TASK_IDS.includes(id)) {
|
|
185
|
+
return { ok: false, error: `Task #${index + 1} uses the reserved id "${id}" which would shadow a task route` };
|
|
186
|
+
}
|
|
187
|
+
const type = normalizeTaskType(raw.type);
|
|
188
|
+
const typeError = validateTaskType(type, raw);
|
|
189
|
+
if (typeError) return { ok: false, error: `Task #${index + 1} ("${raw.title}"): ${typeError}` };
|
|
190
|
+
const channels = Array.isArray(raw.channels) ? raw.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined;
|
|
191
|
+
// Whitelist: drop anything the file tried to smuggle in (counters, history
|
|
192
|
+
// pointers, unknown keys) and never inherit the file's status.
|
|
193
|
+
const clean = { status: 'paused', type };
|
|
194
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
195
|
+
if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
|
|
196
|
+
}
|
|
197
|
+
if (id) clean.id = id;
|
|
198
|
+
if (channels) clean.channels = channels;
|
|
199
|
+
tasks.push(clean);
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, tasks };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** True when any task in a validated batch executes code on the host. */
|
|
205
|
+
export function hasCodeExecutingTask(tasks) {
|
|
206
|
+
const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
207
|
+
return (tasks || []).some((task) => CODE_TYPES.includes(normalizeTaskType(task.type)));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Decide what an import would do, without touching anything. Existing ids are
|
|
212
|
+
* compared against tasks already in the store.
|
|
213
|
+
*/
|
|
214
|
+
export function planImport(tasks, existingIds, strategy = 'skip') {
|
|
215
|
+
const mode = IMPORT_STRATEGIES.includes(strategy) ? strategy : 'skip';
|
|
216
|
+
const existing = new Set(existingIds || []);
|
|
217
|
+
const plan = { strategy: mode, add: [], replace: [], skip: [] };
|
|
218
|
+
for (const task of tasks || []) {
|
|
219
|
+
const hasId = Boolean(task.id) && existing.has(task.id);
|
|
220
|
+
if (!hasId) plan.add.push(task);
|
|
221
|
+
else if (mode === 'replace') plan.replace.push(task);
|
|
222
|
+
else if (mode === 'skip') plan.skip.push(task);
|
|
223
|
+
else plan.add.push({ ...task, id: undefined });
|
|
224
|
+
}
|
|
225
|
+
return plan;
|
|
226
|
+
}
|
package/lib/telegram.js
CHANGED
|
@@ -37,13 +37,27 @@ export function extractFirstAllowedChatId(text) {
|
|
|
37
37
|
return undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Harness home for this process. DSH_HOME wins so an isolated profile reads
|
|
42
|
+
* its own settings file instead of another home's (same rule as the store).
|
|
43
|
+
*/
|
|
44
|
+
export function getDshHomeDir() {
|
|
45
|
+
const fromEnv = process.env.DSH_HOME;
|
|
46
|
+
if (fromEnv && String(fromEnv).trim()) return String(fromEnv).trim();
|
|
47
|
+
return path.join(process.env.HOME || os.homedir(), '.dsh');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getDshSettingsPath() {
|
|
51
|
+
return path.join(getDshHomeDir(), 'settings.yaml');
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
/**
|
|
41
55
|
* Read system DSH settings for fallback credentials.
|
|
42
56
|
* Fails soft with a logged warning so silent misconfiguration is visible (#93).
|
|
43
57
|
*/
|
|
44
58
|
export function getDshDefaultTelegramCredentials() {
|
|
45
59
|
try {
|
|
46
|
-
const p =
|
|
60
|
+
const p = getDshSettingsPath();
|
|
47
61
|
if (fs.existsSync(p)) {
|
|
48
62
|
let raw;
|
|
49
63
|
try {
|
|
@@ -105,7 +119,7 @@ export function formatTaskTelegramMessage(task, runInfo) {
|
|
|
105
119
|
/**
|
|
106
120
|
* Send telegram message via Bot API.
|
|
107
121
|
*/
|
|
108
|
-
export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown' }) {
|
|
122
|
+
export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown', fetchFn = globalThis.fetch, signal }) {
|
|
109
123
|
if (!botToken) throw new Error('Telegram botToken is not configured');
|
|
110
124
|
if (!chatId) throw new Error('Telegram chatId is not configured');
|
|
111
125
|
|
|
@@ -117,20 +131,22 @@ export async function sendTelegramMessage({ botToken, chatId, text, parseMode =
|
|
|
117
131
|
};
|
|
118
132
|
if (parseMode) body.parse_mode = parseMode;
|
|
119
133
|
|
|
120
|
-
const res = await
|
|
134
|
+
const res = await fetchFn(url, {
|
|
121
135
|
method: 'POST',
|
|
122
136
|
headers: { 'Content-Type': 'application/json' },
|
|
123
137
|
body: JSON.stringify(body),
|
|
138
|
+
signal,
|
|
124
139
|
});
|
|
125
140
|
|
|
126
141
|
const data = await res.json().catch(() => ({}));
|
|
127
142
|
if (!res.ok || !data.ok) {
|
|
128
143
|
if (parseMode && data.description && data.description.includes("can't parse entities")) {
|
|
129
144
|
delete body.parse_mode;
|
|
130
|
-
const retryRes = await
|
|
145
|
+
const retryRes = await fetchFn(url, {
|
|
131
146
|
method: 'POST',
|
|
132
147
|
headers: { 'Content-Type': 'application/json' },
|
|
133
148
|
body: JSON.stringify(body),
|
|
149
|
+
signal,
|
|
134
150
|
});
|
|
135
151
|
const retryData = await retryRes.json().catch(() => ({}));
|
|
136
152
|
if (retryRes.ok && retryData.ok) {
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification template engine (#25).
|
|
3
|
+
*
|
|
4
|
+
* Messages for every channel are rendered from a template with `{var}`
|
|
5
|
+
* placeholders. Pure functions only — no I/O — so the whole matrix is
|
|
6
|
+
* unit-testable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const TEMPLATE_VARIABLES = [
|
|
10
|
+
'title',
|
|
11
|
+
'id',
|
|
12
|
+
'status',
|
|
13
|
+
'output',
|
|
14
|
+
'error',
|
|
15
|
+
'duration',
|
|
16
|
+
'schedule',
|
|
17
|
+
'time',
|
|
18
|
+
'tokens',
|
|
19
|
+
'cost',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_PLAIN_TEMPLATE = '⏰ {title}\nStatus: {status}\nSchedule: {schedule}\nDuration: {duration}\n{output}';
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_FAILURE_TEMPLATE = '❌ {title} failed\nSchedule: {schedule}\nDuration: {duration}\n{error}';
|
|
25
|
+
|
|
26
|
+
const MAX_FIELD = 2000;
|
|
27
|
+
|
|
28
|
+
export function truncateText(text, limit = MAX_FIELD) {
|
|
29
|
+
const s = String(text == null ? '' : text);
|
|
30
|
+
if (s.length <= limit) return s;
|
|
31
|
+
return s.slice(0, limit) + '…';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatDuration(ms) {
|
|
35
|
+
if (ms === null || ms === undefined || ms === '') return '—';
|
|
36
|
+
const n = Number(ms);
|
|
37
|
+
if (!Number.isFinite(n) || n < 0) return '—';
|
|
38
|
+
if (n < 1000) return `${Math.round(n)} ms`;
|
|
39
|
+
const seconds = n / 1000;
|
|
40
|
+
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
|
41
|
+
const minutes = Math.floor(seconds / 60);
|
|
42
|
+
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build the placeholder map for one run. */
|
|
46
|
+
export function buildTemplateVars(task, runInfo = {}) {
|
|
47
|
+
const status = runInfo.status || 'unknown';
|
|
48
|
+
const output = status === 'success' ? (runInfo.output || '') : '';
|
|
49
|
+
const error = runInfo.error || (status === 'error' || status === 'timeout' ? runInfo.output || '' : '');
|
|
50
|
+
const usage = runInfo.usage || {};
|
|
51
|
+
const tokens = (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0);
|
|
52
|
+
return {
|
|
53
|
+
title: task.title || 'Task',
|
|
54
|
+
id: task.id || '',
|
|
55
|
+
status,
|
|
56
|
+
output: truncateText(output),
|
|
57
|
+
error: truncateText(error),
|
|
58
|
+
duration: formatDuration(runInfo.durationMs),
|
|
59
|
+
schedule: task.scheduleText || task.schedule || '',
|
|
60
|
+
time: new Date(runInfo.at || Date.now()).toISOString(),
|
|
61
|
+
tokens: String(tokens),
|
|
62
|
+
cost: `$${Number(runInfo.costUsd || 0).toFixed(4)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Replace {var} placeholders; unknown placeholders are left untouched. */
|
|
67
|
+
export function renderTemplate(template, vars) {
|
|
68
|
+
return String(template == null ? '' : template).replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (match, name) => {
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(vars, name)) {
|
|
70
|
+
const value = vars[name];
|
|
71
|
+
return value === null || value === undefined ? '' : String(value);
|
|
72
|
+
}
|
|
73
|
+
return match;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pick the template for a run: failure template for failed runs unless a
|
|
79
|
+
* custom template is configured, plain template otherwise.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveTemplateText({ template, task, runInfo }) {
|
|
82
|
+
const custom = typeof template === 'string' && template.trim() ? template.trim() : '';
|
|
83
|
+
if (custom) return renderTemplate(custom, buildTemplateVars(task, runInfo));
|
|
84
|
+
const failed = runInfo.status === 'error' || runInfo.status === 'timeout';
|
|
85
|
+
const base = failed ? DEFAULT_FAILURE_TEMPLATE : DEFAULT_PLAIN_TEMPLATE;
|
|
86
|
+
const text = renderTemplate(base, buildTemplateVars(task, runInfo));
|
|
87
|
+
return text.replace(/\n{3,}/g, '\n\n').trim();
|
|
88
|
+
}
|