@katmer/core 0.0.3
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 +1 -0
- package/cli/katmer.js +28 -0
- package/cli/run.ts +16 -0
- package/index.ts +5 -0
- package/lib/config.ts +82 -0
- package/lib/interfaces/config.interface.ts +113 -0
- package/lib/interfaces/executor.interface.ts +13 -0
- package/lib/interfaces/module.interface.ts +170 -0
- package/lib/interfaces/provider.interface.ts +214 -0
- package/lib/interfaces/task.interface.ts +100 -0
- package/lib/katmer.ts +126 -0
- package/lib/lookup/env.lookup.ts +13 -0
- package/lib/lookup/file.lookup.ts +23 -0
- package/lib/lookup/index.ts +46 -0
- package/lib/lookup/url.lookup.ts +21 -0
- package/lib/lookup/var.lookup.ts +13 -0
- package/lib/module.ts +560 -0
- package/lib/module_registry.ts +64 -0
- package/lib/modules/apt-repository/apt-repository.module.ts +435 -0
- package/lib/modules/apt-repository/apt-sources-list.ts +363 -0
- package/lib/modules/apt.module.ts +546 -0
- package/lib/modules/archive.module.ts +280 -0
- package/lib/modules/become.module.ts +119 -0
- package/lib/modules/copy.module.ts +807 -0
- package/lib/modules/cron.module.ts +541 -0
- package/lib/modules/debug.module.ts +231 -0
- package/lib/modules/gather_facts.module.ts +605 -0
- package/lib/modules/git.module.ts +243 -0
- package/lib/modules/hostname.module.ts +213 -0
- package/lib/modules/http/http.curl.module.ts +342 -0
- package/lib/modules/http/http.local.module.ts +253 -0
- package/lib/modules/http/http.module.ts +298 -0
- package/lib/modules/index.ts +14 -0
- package/lib/modules/package.module.ts +283 -0
- package/lib/modules/script.module.ts +121 -0
- package/lib/modules/set_fact.module.ts +171 -0
- package/lib/modules/systemd_service.module.ts +373 -0
- package/lib/modules/template.module.ts +478 -0
- package/lib/providers/local.provider.ts +336 -0
- package/lib/providers/provider_response.ts +20 -0
- package/lib/providers/ssh/ssh.provider.ts +420 -0
- package/lib/providers/ssh/ssh.utils.ts +31 -0
- package/lib/schemas/katmer_config.schema.json +358 -0
- package/lib/target_resolver.ts +298 -0
- package/lib/task/controls/environment.control.ts +42 -0
- package/lib/task/controls/index.ts +13 -0
- package/lib/task/controls/loop.control.ts +89 -0
- package/lib/task/controls/register.control.ts +23 -0
- package/lib/task/controls/until.control.ts +64 -0
- package/lib/task/controls/when.control.ts +25 -0
- package/lib/task/task.ts +225 -0
- package/lib/utils/ajv.utils.ts +24 -0
- package/lib/utils/cls.ts +4 -0
- package/lib/utils/datetime.utils.ts +15 -0
- package/lib/utils/errors.ts +25 -0
- package/lib/utils/execute-shell.ts +116 -0
- package/lib/utils/file.utils.ts +68 -0
- package/lib/utils/http.utils.ts +10 -0
- package/lib/utils/json.utils.ts +15 -0
- package/lib/utils/number.utils.ts +9 -0
- package/lib/utils/object.utils.ts +11 -0
- package/lib/utils/os.utils.ts +31 -0
- package/lib/utils/path.utils.ts +9 -0
- package/lib/utils/renderer/render_functions.ts +3 -0
- package/lib/utils/renderer/renderer.ts +89 -0
- package/lib/utils/renderer/twig.ts +191 -0
- package/lib/utils/string.utils.ts +33 -0
- package/lib/utils/typed-event-emitter.ts +26 -0
- package/lib/utils/unix.utils.ts +91 -0
- package/lib/utils/windows.utils.ts +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ModuleCommonReturn,
|
|
3
|
+
type ModuleConstraints
|
|
4
|
+
} from "../interfaces/module.interface"
|
|
5
|
+
import type { Katmer } from "../interfaces/task.interface"
|
|
6
|
+
import type { SSHProvider } from "../providers/ssh/ssh.provider"
|
|
7
|
+
import { KatmerModule } from "../module"
|
|
8
|
+
|
|
9
|
+
declare module "../interfaces/task.interface" {
|
|
10
|
+
export namespace Katmer {
|
|
11
|
+
export interface TaskActions {
|
|
12
|
+
cron?: CronModuleOptions
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Manage scheduled jobs cross-platform.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* - **POSIX (Linux/macOS/BSD)**: edits the per-user crontab using `crontab -l/-`.
|
|
21
|
+
* - **Windows**: manages a Scheduled Task using `schtasks`.
|
|
22
|
+
* - Idempotent by tracking named blocks (`# KATMER_CRON_START:<name>` / `# KATMER_CRON_END:<name>`) on POSIX.
|
|
23
|
+
* - Supports adding/updating/removing a single job (by `name`) or clearing all jobs with `state: "absent"` and no `name` (dangerous).
|
|
24
|
+
*
|
|
25
|
+
* @examples
|
|
26
|
+
* ```yaml
|
|
27
|
+
* - name: Add Nightly backup (POSIX)
|
|
28
|
+
* cron:
|
|
29
|
+
* name: "nightly-backup"
|
|
30
|
+
* minute: "0"
|
|
31
|
+
* hour: "3"
|
|
32
|
+
* job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1"
|
|
33
|
+
* user: "root"
|
|
34
|
+
*
|
|
35
|
+
* - name: Remove nightly backup (POSIX)
|
|
36
|
+
* cron:
|
|
37
|
+
* name: "nightly-backup"
|
|
38
|
+
* state: absent
|
|
39
|
+
* user: "root"
|
|
40
|
+
*
|
|
41
|
+
* - name: Re-index daily (Windows)
|
|
42
|
+
* cron:
|
|
43
|
+
* name: "reindex"
|
|
44
|
+
* job: "C:\\Program Files\\MyApp\\reindex.exe"
|
|
45
|
+
* at: "02:00"
|
|
46
|
+
* frequency: DAILY
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
export class CronModule extends KatmerModule<
|
|
52
|
+
CronModuleOptions,
|
|
53
|
+
CronModuleResult,
|
|
54
|
+
SSHProvider
|
|
55
|
+
> {
|
|
56
|
+
// Cross-platform: allow all, with a Linux package hint for cron; Windows supported.
|
|
57
|
+
constraints = {
|
|
58
|
+
platform: {
|
|
59
|
+
linux: {
|
|
60
|
+
binaries: [{ cmd: "sh" }], // sanity
|
|
61
|
+
packages: [
|
|
62
|
+
{
|
|
63
|
+
// any of these ok, with minimal version
|
|
64
|
+
name: "cron",
|
|
65
|
+
range: ">=3.0",
|
|
66
|
+
alternatives: [
|
|
67
|
+
{ name: "cronie", range: ">=1.5" },
|
|
68
|
+
{ name: "dcron", range: ">=4.5" }
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
distro: {
|
|
73
|
+
alpine: { packages: [{ name: "dcron", range: ">=4.5" }] },
|
|
74
|
+
arch: { packages: [{ name: "cronie", range: ">=1.6" }] }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
darwin: {
|
|
78
|
+
// we use crontab; optionally require it:
|
|
79
|
+
binaries: [{ cmd: "crontab" }]
|
|
80
|
+
},
|
|
81
|
+
windows: {
|
|
82
|
+
// uses schtasks; ensure PowerShell exists & version:
|
|
83
|
+
binaries: [
|
|
84
|
+
{ cmd: "powershell", versionRegex: /([\d.]+)/, range: ">=5.1" }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} satisfies ModuleConstraints
|
|
89
|
+
|
|
90
|
+
static name = "cron" as const
|
|
91
|
+
|
|
92
|
+
async check(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
|
|
93
|
+
const p = this.params || ({} as CronModuleOptions)
|
|
94
|
+
if (p.state !== "absent" && (!p.name || !p.job)) {
|
|
95
|
+
throw new Error("'name' and 'job' are required when state != absent")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async initialize(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
|
|
100
|
+
async cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
|
|
101
|
+
|
|
102
|
+
async execute(
|
|
103
|
+
ctx: Katmer.TaskContext<SSHProvider>
|
|
104
|
+
): Promise<CronModuleResult> {
|
|
105
|
+
const osfam = ctx.provider.os.family
|
|
106
|
+
|
|
107
|
+
if (osfam === "windows") {
|
|
108
|
+
return await this.executeWindows(ctx)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Default POSIX path (Linux, macOS, BSD, etc.) via crontab
|
|
112
|
+
return await this.executePosixCron(ctx)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* POSIX implementation using per-user `crontab`.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* - If `name` is provided, the job is wrapped by start/end markers for safe updates/removal.
|
|
120
|
+
* - If `state: "absent"` and no `name`, will clear **all** crontab entries for the target user.
|
|
121
|
+
* - Uses a temp file + `crontab <file>` to write idempotently.
|
|
122
|
+
*
|
|
123
|
+
* @param ctx - Katmer task context
|
|
124
|
+
* @returns Result with `changed` and a simple message
|
|
125
|
+
* @throws When reading/writing the crontab fails
|
|
126
|
+
* @internal
|
|
127
|
+
*/
|
|
128
|
+
private async executePosixCron(
|
|
129
|
+
ctx: Katmer.TaskContext<SSHProvider>
|
|
130
|
+
): Promise<CronModuleResult> {
|
|
131
|
+
const {
|
|
132
|
+
name,
|
|
133
|
+
job,
|
|
134
|
+
user,
|
|
135
|
+
state = "present",
|
|
136
|
+
special_time,
|
|
137
|
+
minute = "*",
|
|
138
|
+
hour = "*",
|
|
139
|
+
day = "*",
|
|
140
|
+
month = "*",
|
|
141
|
+
weekday = "*",
|
|
142
|
+
disabled = false,
|
|
143
|
+
env,
|
|
144
|
+
backup = false
|
|
145
|
+
} = this.params
|
|
146
|
+
|
|
147
|
+
const runAs = user ? `sudo -u ${q(user)} ` : ""
|
|
148
|
+
const markerStart = name ? `# KATMER_CRON_START:${name}` : ""
|
|
149
|
+
const markerEnd = name ? `# KATMER_CRON_END:${name}` : ""
|
|
150
|
+
const header = name ? [markerStart] : []
|
|
151
|
+
const footer = name ? [markerEnd] : []
|
|
152
|
+
|
|
153
|
+
// Read current crontab for user
|
|
154
|
+
const getCmd = `${runAs}crontab -l 2>/dev/null || true`
|
|
155
|
+
const current = await ctx.exec(getCmd)
|
|
156
|
+
if (current.code !== 0) {
|
|
157
|
+
throw {
|
|
158
|
+
changed: false,
|
|
159
|
+
msg: current.stderr || "failed to read crontab"
|
|
160
|
+
} satisfies CronModuleResult
|
|
161
|
+
}
|
|
162
|
+
const originalCrontab = (current.stdout || "").replace(/\r/g, "")
|
|
163
|
+
let lines = splitLines(originalCrontab)
|
|
164
|
+
|
|
165
|
+
// Backup if asked
|
|
166
|
+
if (backup && originalCrontab.trim()) {
|
|
167
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-")
|
|
168
|
+
await ctx.exec(
|
|
169
|
+
`${runAs}crontab -l > ${q(
|
|
170
|
+
`/tmp/crontab-${user || "root"}-${ts}.bak`
|
|
171
|
+
)} 2>/dev/null || true`
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Remove existing block for this name (if tracked)
|
|
176
|
+
if (name) {
|
|
177
|
+
lines = stripBlock(lines, markerStart, markerEnd)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let changed = false
|
|
181
|
+
|
|
182
|
+
if (state === "absent") {
|
|
183
|
+
// If name provided, removal already done by stripBlock; detect change
|
|
184
|
+
if (name) {
|
|
185
|
+
changed = originalCrontab !== lines.join("\n")
|
|
186
|
+
} else {
|
|
187
|
+
// Dangerous: clear all
|
|
188
|
+
if (originalCrontab.trim().length > 0) {
|
|
189
|
+
lines = []
|
|
190
|
+
changed = true
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// Build entry lines
|
|
195
|
+
const entryLines: string[] = []
|
|
196
|
+
|
|
197
|
+
// environment variables section (optional)
|
|
198
|
+
if (env) {
|
|
199
|
+
for (const [k, v] of Object.entries(env)) {
|
|
200
|
+
entryLines.push(`${k}=${formatEnvValue(v)}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// job line
|
|
205
|
+
const cronLine =
|
|
206
|
+
special_time ?
|
|
207
|
+
`${disabled ? "# " : ""}${special("@", special_time)} ${job}`
|
|
208
|
+
: `${disabled ? "# " : ""}${minute} ${hour} ${day} ${month} ${weekday} ${job}`
|
|
209
|
+
|
|
210
|
+
if (name) entryLines.unshift(...header)
|
|
211
|
+
entryLines.push(cronLine)
|
|
212
|
+
if (name) entryLines.push(...footer)
|
|
213
|
+
|
|
214
|
+
// Append a separator newline between blocks when needed
|
|
215
|
+
if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("")
|
|
216
|
+
lines.push(...entryLines)
|
|
217
|
+
|
|
218
|
+
// Detect change
|
|
219
|
+
changed = originalCrontab !== lines.join("\n")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (changed) {
|
|
223
|
+
// Write back
|
|
224
|
+
// Ensure final ends with newline
|
|
225
|
+
let finalBody = lines.join("\n")
|
|
226
|
+
if (!finalBody.endsWith("\n")) finalBody += "\n"
|
|
227
|
+
|
|
228
|
+
const tmp = `/tmp/katmer-cron-${Date.now()}-${Math.random()
|
|
229
|
+
.toString(36)
|
|
230
|
+
.slice(2)}.tmp`
|
|
231
|
+
const writeTmp = await ctx.exec(`cat > ${q(tmp)} << "KATMER_EOF"
|
|
232
|
+
${finalBody}
|
|
233
|
+
KATMER_EOF`)
|
|
234
|
+
if (writeTmp.code !== 0) {
|
|
235
|
+
await ctx.exec(`rm -f ${q(tmp)}`).catch(() => {})
|
|
236
|
+
throw {
|
|
237
|
+
changed: false,
|
|
238
|
+
msg:
|
|
239
|
+
writeTmp.stderr || writeTmp.stdout || "failed to stage new crontab"
|
|
240
|
+
} satisfies CronModuleResult
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const load = await ctx.exec(`${runAs}crontab ${q(tmp)}`)
|
|
244
|
+
await ctx.exec(`rm -f ${q(tmp)}`).catch(() => {})
|
|
245
|
+
if (load.code !== 0) {
|
|
246
|
+
throw {
|
|
247
|
+
changed: false,
|
|
248
|
+
msg: load.stderr || load.stdout || "failed to install new crontab"
|
|
249
|
+
} satisfies CronModuleResult
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
changed,
|
|
255
|
+
stdout: changed ? "crontab updated" : "no change",
|
|
256
|
+
stderr: ""
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------- Windows (schtasks) ----------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Windows implementation using `schtasks`.
|
|
264
|
+
*
|
|
265
|
+
* @remarks
|
|
266
|
+
* - Creates or updates a named task (`name`) to run the provided command (`job`).
|
|
267
|
+
* - Uses `at` (HH:mm) and `frequency` when provided; otherwise, attempts to derive time from POSIX `minute`/`hour`.
|
|
268
|
+
* - On schedule change where `/Change` is insufficient, we delete and recreate the task for idempotency.
|
|
269
|
+
*
|
|
270
|
+
* @param ctx - Katmer task context
|
|
271
|
+
* @returns Result with `changed` and the raw CLI output on failure
|
|
272
|
+
* @internal
|
|
273
|
+
*/
|
|
274
|
+
private async executeWindows(
|
|
275
|
+
ctx: Katmer.TaskContext<SSHProvider>
|
|
276
|
+
): Promise<CronModuleResult> {
|
|
277
|
+
const {
|
|
278
|
+
name,
|
|
279
|
+
job,
|
|
280
|
+
state = "present",
|
|
281
|
+
user,
|
|
282
|
+
at,
|
|
283
|
+
frequency
|
|
284
|
+
} = this.params as CronModuleOptions & {
|
|
285
|
+
at?: string
|
|
286
|
+
frequency?: WindowsFrequency
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!name || !job) {
|
|
290
|
+
return {
|
|
291
|
+
changed: false,
|
|
292
|
+
failed: true,
|
|
293
|
+
msg: "windows: 'name' and 'job' are required"
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Try to derive /ST from cron fields if not provided
|
|
298
|
+
const st =
|
|
299
|
+
at && isValidHHMM(at) ? at : (
|
|
300
|
+
deriveHHMMFromCron(this.params.minute, this.params.hour) || "12:00"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
const sc: WindowsFrequency = frequency || "DAILY"
|
|
304
|
+
|
|
305
|
+
const jsonQ = (s: string) => JSON.stringify(s)
|
|
306
|
+
const tn = jsonQ(name)
|
|
307
|
+
|
|
308
|
+
if (state === "absent") {
|
|
309
|
+
const del = await ctx.exec(`schtasks /Delete /TN ${tn} /F`)
|
|
310
|
+
const ok = del.code === 0
|
|
311
|
+
return {
|
|
312
|
+
changed: ok,
|
|
313
|
+
failed: false,
|
|
314
|
+
msg: ok ? "deleted" : del.stderr || del.stdout
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Create (or replace if exists)
|
|
319
|
+
const createCmd = [
|
|
320
|
+
`schtasks /Create /TN ${tn}`,
|
|
321
|
+
`/TR ${jsonQ(job)}`,
|
|
322
|
+
`/SC ${sc}`,
|
|
323
|
+
`/ST ${jsonQ(st)}`,
|
|
324
|
+
user ? `/RU ${jsonQ(user)}` : ""
|
|
325
|
+
]
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
.join(" ")
|
|
328
|
+
|
|
329
|
+
let r = await ctx.exec(createCmd)
|
|
330
|
+
|
|
331
|
+
if (r.code !== 0) {
|
|
332
|
+
// If exists, delete and recreate to be idempotent across schedule changes
|
|
333
|
+
if (/already exists/i.test(r.stderr || r.stdout || "")) {
|
|
334
|
+
const del = await ctx.exec(`schtasks /Delete /TN ${tn} /F`)
|
|
335
|
+
if (del.code === 0) {
|
|
336
|
+
r = await ctx.exec(createCmd)
|
|
337
|
+
} else {
|
|
338
|
+
r = del
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ok = r.code === 0
|
|
344
|
+
return { changed: ok, failed: !ok, msg: r.stderr || r.stdout }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Options for cron module (cross-platform).
|
|
350
|
+
* On Windows, you may optionally use `at` (HH:mm) and `frequency`.
|
|
351
|
+
*/
|
|
352
|
+
export interface CronModuleOptions {
|
|
353
|
+
/**
|
|
354
|
+
* Unique job identifier.
|
|
355
|
+
* - POSIX: used to mark/update the block in crontab.
|
|
356
|
+
* - Windows: used as the Scheduled Task name.
|
|
357
|
+
*/
|
|
358
|
+
name?: string
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Command to execute.
|
|
362
|
+
* - Required when `state: "present"`.
|
|
363
|
+
*/
|
|
364
|
+
job?: string
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Target user whose crontab/task is managed.
|
|
368
|
+
* - POSIX: affects which user's crontab is edited (via `sudo -u`).
|
|
369
|
+
* - Windows: used as `/RU` when provided.
|
|
370
|
+
*/
|
|
371
|
+
user?: string
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Desired presence.
|
|
375
|
+
* @defaultValue "present"
|
|
376
|
+
*/
|
|
377
|
+
state?: "present" | "absent"
|
|
378
|
+
|
|
379
|
+
// -------- POSIX cron fields --------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* One of: `yearly`, `annually`, `monthly`, `weekly`, `daily`, `hourly`, `reboot`.
|
|
383
|
+
* Mutually exclusive with `minute/hour/day/month/weekday`.
|
|
384
|
+
*/
|
|
385
|
+
special_time?:
|
|
386
|
+
| "reboot"
|
|
387
|
+
| "yearly"
|
|
388
|
+
| "annually"
|
|
389
|
+
| "monthly"
|
|
390
|
+
| "weekly"
|
|
391
|
+
| "daily"
|
|
392
|
+
| "hourly"
|
|
393
|
+
|
|
394
|
+
/** Cron minute field. Ignored when `special_time` is set. */
|
|
395
|
+
minute?: string
|
|
396
|
+
/** Cron hour field. Ignored when `special_time` is set. */
|
|
397
|
+
hour?: string
|
|
398
|
+
/** Cron day-of-month field. Ignored when `special_time` is set. */
|
|
399
|
+
day?: string
|
|
400
|
+
/** Cron month field. Ignored when `special_time` is set. */
|
|
401
|
+
month?: string
|
|
402
|
+
/** Cron day-of-week field. Ignored when `special_time` is set. */
|
|
403
|
+
weekday?: string
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Comment out (disable) the job but keep it in crontab (POSIX).
|
|
407
|
+
* @defaultValue false
|
|
408
|
+
*/
|
|
409
|
+
disabled?: boolean
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Environment variables to prepend to the job block (POSIX).
|
|
413
|
+
* @example `{ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin", MAILTO: "ops@example.com" }`
|
|
414
|
+
*/
|
|
415
|
+
env?: Record<string, string | number | boolean>
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* If `true`, saves the existing crontab to `/tmp` before modifying (POSIX).
|
|
419
|
+
* @defaultValue false
|
|
420
|
+
*/
|
|
421
|
+
backup?: boolean
|
|
422
|
+
|
|
423
|
+
// -------- Windows convenience --------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Time for `schtasks` in 24-hour `HH:mm` format.
|
|
427
|
+
* If omitted, it is derived from `minute`/`hour` when both are numeric.
|
|
428
|
+
*/
|
|
429
|
+
at?: string
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `schtasks` frequency.
|
|
433
|
+
* @defaultValue "DAILY"
|
|
434
|
+
*/
|
|
435
|
+
frequency?: WindowsFrequency
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Allowed values for the Windows `schtasks /SC` option.
|
|
440
|
+
* @public
|
|
441
|
+
*/
|
|
442
|
+
export type WindowsFrequency =
|
|
443
|
+
| "MINUTE"
|
|
444
|
+
| "HOURLY"
|
|
445
|
+
| "DAILY"
|
|
446
|
+
| "WEEKLY"
|
|
447
|
+
| "MONTHLY"
|
|
448
|
+
| "ONCE"
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Result for {@link CronModule}.
|
|
452
|
+
* @public
|
|
453
|
+
*/
|
|
454
|
+
export interface CronModuleResult extends ModuleCommonReturn {}
|
|
455
|
+
|
|
456
|
+
/* -------------------- helpers -------------------- */
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* JSON-quotes a string for shell-safe heredoc usage.
|
|
460
|
+
* @internal
|
|
461
|
+
*/
|
|
462
|
+
function q(s: string) {
|
|
463
|
+
return JSON.stringify(s)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Split a multi-line string into lines (normalizing CRLF).
|
|
468
|
+
* @internal
|
|
469
|
+
*/
|
|
470
|
+
function splitLines(s: string): string[] {
|
|
471
|
+
return (s || "").replace(/\r/g, "").split("\n")
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Remove an inclusive block between exact `start` and `end` marker lines.
|
|
476
|
+
* @internal
|
|
477
|
+
*/
|
|
478
|
+
function stripBlock(lines: string[], start: string, end: string): string[] {
|
|
479
|
+
if (!start || !end) return lines
|
|
480
|
+
const out: string[] = []
|
|
481
|
+
let skip = false
|
|
482
|
+
for (const line of lines) {
|
|
483
|
+
if (!skip && line.trim() === start) {
|
|
484
|
+
skip = true
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if (skip && line.trim() === end) {
|
|
488
|
+
skip = false
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
if (!skip) out.push(line)
|
|
492
|
+
}
|
|
493
|
+
while (out.length && out[out.length - 1].trim() === "") out.pop()
|
|
494
|
+
return out
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Format a `@special` token or return `""` if not set.
|
|
499
|
+
* @internal
|
|
500
|
+
*/
|
|
501
|
+
function special(prefix: "@" | "", t?: CronModuleOptions["special_time"]) {
|
|
502
|
+
if (!t) return ""
|
|
503
|
+
return `${prefix}${t}`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Render an env value for POSIX crontab (quote if needed).
|
|
508
|
+
* @internal
|
|
509
|
+
*/
|
|
510
|
+
function formatEnvValue(v: string | number | boolean): string {
|
|
511
|
+
if (typeof v === "boolean") return v ? "true" : "false"
|
|
512
|
+
if (typeof v === "number") return String(v)
|
|
513
|
+
// Quote if contains spaces or special chars
|
|
514
|
+
return /[\s"'`$\\]/.test(v) ? JSON.stringify(v) : v
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Derive `HH:mm` when both `minute` & `hour` are exact integers.
|
|
519
|
+
* @internal
|
|
520
|
+
*/
|
|
521
|
+
function deriveHHMMFromCron(
|
|
522
|
+
minute?: string,
|
|
523
|
+
hour?: string
|
|
524
|
+
): string | undefined {
|
|
525
|
+
if (!minute || !hour) return undefined
|
|
526
|
+
if (!/^\d{1,2}$/.test(minute) || !/^\d{1,2}$/.test(hour)) return undefined
|
|
527
|
+
const mi = parseInt(minute, 10)
|
|
528
|
+
const hr = parseInt(hour, 10)
|
|
529
|
+
if (mi < 0 || mi > 59 || hr < 0 || hr > 23) return undefined
|
|
530
|
+
return pad2(hr) + ":" + pad2(mi)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** @internal */
|
|
534
|
+
function pad2(n: number) {
|
|
535
|
+
return n < 10 ? "0" + n : String(n)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** @internal */
|
|
539
|
+
function isValidHHMM(s: string): boolean {
|
|
540
|
+
return /^([0-1]\d|2[0-3]):[0-5]\d$/.test(s)
|
|
541
|
+
}
|