@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.
Files changed (71) hide show
  1. package/README.md +1 -0
  2. package/cli/katmer.js +28 -0
  3. package/cli/run.ts +16 -0
  4. package/index.ts +5 -0
  5. package/lib/config.ts +82 -0
  6. package/lib/interfaces/config.interface.ts +113 -0
  7. package/lib/interfaces/executor.interface.ts +13 -0
  8. package/lib/interfaces/module.interface.ts +170 -0
  9. package/lib/interfaces/provider.interface.ts +214 -0
  10. package/lib/interfaces/task.interface.ts +100 -0
  11. package/lib/katmer.ts +126 -0
  12. package/lib/lookup/env.lookup.ts +13 -0
  13. package/lib/lookup/file.lookup.ts +23 -0
  14. package/lib/lookup/index.ts +46 -0
  15. package/lib/lookup/url.lookup.ts +21 -0
  16. package/lib/lookup/var.lookup.ts +13 -0
  17. package/lib/module.ts +560 -0
  18. package/lib/module_registry.ts +64 -0
  19. package/lib/modules/apt-repository/apt-repository.module.ts +435 -0
  20. package/lib/modules/apt-repository/apt-sources-list.ts +363 -0
  21. package/lib/modules/apt.module.ts +546 -0
  22. package/lib/modules/archive.module.ts +280 -0
  23. package/lib/modules/become.module.ts +119 -0
  24. package/lib/modules/copy.module.ts +807 -0
  25. package/lib/modules/cron.module.ts +541 -0
  26. package/lib/modules/debug.module.ts +231 -0
  27. package/lib/modules/gather_facts.module.ts +605 -0
  28. package/lib/modules/git.module.ts +243 -0
  29. package/lib/modules/hostname.module.ts +213 -0
  30. package/lib/modules/http/http.curl.module.ts +342 -0
  31. package/lib/modules/http/http.local.module.ts +253 -0
  32. package/lib/modules/http/http.module.ts +298 -0
  33. package/lib/modules/index.ts +14 -0
  34. package/lib/modules/package.module.ts +283 -0
  35. package/lib/modules/script.module.ts +121 -0
  36. package/lib/modules/set_fact.module.ts +171 -0
  37. package/lib/modules/systemd_service.module.ts +373 -0
  38. package/lib/modules/template.module.ts +478 -0
  39. package/lib/providers/local.provider.ts +336 -0
  40. package/lib/providers/provider_response.ts +20 -0
  41. package/lib/providers/ssh/ssh.provider.ts +420 -0
  42. package/lib/providers/ssh/ssh.utils.ts +31 -0
  43. package/lib/schemas/katmer_config.schema.json +358 -0
  44. package/lib/target_resolver.ts +298 -0
  45. package/lib/task/controls/environment.control.ts +42 -0
  46. package/lib/task/controls/index.ts +13 -0
  47. package/lib/task/controls/loop.control.ts +89 -0
  48. package/lib/task/controls/register.control.ts +23 -0
  49. package/lib/task/controls/until.control.ts +64 -0
  50. package/lib/task/controls/when.control.ts +25 -0
  51. package/lib/task/task.ts +225 -0
  52. package/lib/utils/ajv.utils.ts +24 -0
  53. package/lib/utils/cls.ts +4 -0
  54. package/lib/utils/datetime.utils.ts +15 -0
  55. package/lib/utils/errors.ts +25 -0
  56. package/lib/utils/execute-shell.ts +116 -0
  57. package/lib/utils/file.utils.ts +68 -0
  58. package/lib/utils/http.utils.ts +10 -0
  59. package/lib/utils/json.utils.ts +15 -0
  60. package/lib/utils/number.utils.ts +9 -0
  61. package/lib/utils/object.utils.ts +11 -0
  62. package/lib/utils/os.utils.ts +31 -0
  63. package/lib/utils/path.utils.ts +9 -0
  64. package/lib/utils/renderer/render_functions.ts +3 -0
  65. package/lib/utils/renderer/renderer.ts +89 -0
  66. package/lib/utils/renderer/twig.ts +191 -0
  67. package/lib/utils/string.utils.ts +33 -0
  68. package/lib/utils/typed-event-emitter.ts +26 -0
  69. package/lib/utils/unix.utils.ts +91 -0
  70. package/lib/utils/windows.utils.ts +92 -0
  71. 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
+ }