@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,478 @@
1
+ // packages/core/modules/template.module.ts
2
+ import {
3
+ type ModuleCommonReturn,
4
+ type ModuleConstraints
5
+ } from "../interfaces/module.interface"
6
+ import type { Katmer } from "../interfaces/task.interface"
7
+ import type { KatmerProvider } from "../interfaces/provider.interface"
8
+ import { SSHProvider } from "../providers/ssh/ssh.provider"
9
+ import type { LocalProvider } from "../providers/local.provider"
10
+ import { evalTemplate } from "../utils/renderer/renderer"
11
+ import { toMerged } from "es-toolkit"
12
+ import fs from "node:fs/promises"
13
+ import path from "node:path"
14
+ import { toOctal } from "../utils/number.utils"
15
+ import { KatmerModule } from "../module"
16
+
17
+ declare module "../interfaces/task.interface" {
18
+ export namespace Katmer {
19
+ export interface TaskActions {
20
+ template?: TemplateModuleOptions
21
+ }
22
+ }
23
+ }
24
+ /**
25
+ * Options for the `template` module.
26
+ *
27
+ * Renders a Twig template (from inline `content` or a file `src`) **locally** using
28
+ * the merged data context and writes the rendered result to `dest`.
29
+ * - On **SSH** providers, writes to the remote via shell, preserving atomicity with a temp file.
30
+ * - On **Local** provider, writes directly using Node.js `fs` APIs (no shell).
31
+ *
32
+ * @public
33
+ */
34
+ export interface TemplateModuleOptions {
35
+ /**
36
+ * Inline template string (Twig syntax supported).
37
+ * One of {@link TemplateModuleOptions.content | content} or {@link TemplateModuleOptions.src | src} is required.
38
+ *
39
+ * @example
40
+ * ```yaml
41
+ * template:
42
+ * content: |
43
+ * server_name {{ domain }};
44
+ * listen {{ port }};
45
+ * dest: "/etc/myapp/site.conf"
46
+ * ```
47
+ */
48
+ content?: string
49
+
50
+ /**
51
+ * Local file path to read the template from (Twig syntax supported).
52
+ * One of {@link TemplateModuleOptions.src | src} or {@link TemplateModuleOptions.content | content} is required.
53
+ *
54
+ * @example
55
+ * ```yaml
56
+ * template:
57
+ * src: "./_source/site.conf.twig"
58
+ * dest: "/etc/myapp/site.conf"
59
+ * ```
60
+ */
61
+ src?: string
62
+
63
+ /**
64
+ * Destination path to write the rendered output into.
65
+ * - **SSH**: remote absolute path on the target host.
66
+ * - **Local**: local filesystem path.
67
+ */
68
+ dest: string
69
+
70
+ /**
71
+ * Extra variables merged **on top of** `ctx.variables` for this render only.
72
+ * Task/target variables remain intact; these act as per-render overrides.
73
+ */
74
+ variables?: Record<string, any>
75
+
76
+ /**
77
+ * File permissions to apply on `dest`.
78
+ * Accepts octal number (e.g., `0o644`) or string (e.g., `"0644"`).
79
+ * @defaultValue `"0644"`
80
+ */
81
+ mode?: string | number
82
+
83
+ /**
84
+ * File owner to apply on `dest`.
85
+ * - **SSH**: accepts a username (e.g., `"root"`).
86
+ * - **Local**: must be **numeric uid**; otherwise the chown attempt is skipped and a warning is logged.
87
+ */
88
+ owner?: string
89
+
90
+ /**
91
+ * File group to apply on `dest`.
92
+ * - **SSH**: accepts a group name (e.g., `"www-data"`).
93
+ * - **Local**: must be **numeric gid**; otherwise the chown attempt is skipped and a warning is logged.
94
+ */
95
+ group?: string
96
+
97
+ /**
98
+ * If `true`, do not write anything; returns the rendered content in {@link TemplateModuleResult.stdout | stdout}.
99
+ * @defaultValue false
100
+ */
101
+ dry_run?: boolean
102
+
103
+ /**
104
+ * If `true`, compares the existing file content with the newly rendered result.
105
+ * When identical, the module returns `changed=false` without writing.
106
+ * @defaultValue true
107
+ */
108
+ diff_check?: boolean
109
+ }
110
+
111
+ export interface TemplateModuleResult extends ModuleCommonReturn {
112
+ /** Destination path that was written (or examined). */
113
+ dest?: string
114
+ /** File mode applied to `dest` (octal string, e.g. `"0644"`). */
115
+ mode?: string
116
+ /** File owner applied to `dest`. */
117
+ owner?: string
118
+ /** File group applied to `dest`. */
119
+ group?: string
120
+ }
121
+
122
+ /**
123
+ * Render a Twig template locally and deploy the result to a file (remote for SSH, local for Local).
124
+ *
125
+ * @remarks
126
+ * - Renders with the merged context of `ctx.variables` + per-call `variables`.
127
+ * - If {@link TemplateModuleOptions.diff_check | diff_check} is `true` (default), content is compared to avoid needless writes.
128
+ * - On **SSH**:
129
+ * - Ensures parent directory via shell (`mkdir -p`).
130
+ * - Writes through a temporary file and installs atomically (`install -m … -D` or `mv` fallback).
131
+ * - Applies `chmod`/`chown` when requested.
132
+ * - On **Local**:
133
+ * - Uses pure Node.js (`fs.mkdir`, `fs.readFile`, `fs.writeFile`, `fs.rename`, `fs.chmod`, `fs.chown`).
134
+ * - `owner`/`group` must be numeric (`uid`/`gid`).
135
+ *
136
+ * @examples
137
+ * ```yaml
138
+ * - name: Render inline text template
139
+ * template:
140
+ * content: |
141
+ * server_name {{ domain }};
142
+ * listen {{ port }};
143
+ * dest: "/etc/myapp/config.conf"
144
+ * mode: "0644"
145
+ * owner: "root"
146
+ * group: "root"
147
+ * variables:
148
+ * domain: "example.com"
149
+ * port: 8080
150
+ *
151
+ * - name: Read template from local file
152
+ * template:
153
+ * src: "./_source/metadata.yaml.twig"
154
+ * dest: "/etc/myapp/metadata.yaml"
155
+ * variables:
156
+ * app: "demo"
157
+ *
158
+ * - name: Dry run (only preview rendered content)
159
+ * template:
160
+ * src: "./_source/site.conf.twig"
161
+ * dest: "/etc/nginx/sites-available/site.conf"
162
+ * dry_run: true
163
+ * ```
164
+ *
165
+ * @public
166
+ */
167
+ export class TemplateModule extends KatmerModule<
168
+ TemplateModuleOptions,
169
+ TemplateModuleResult,
170
+ KatmerProvider
171
+ > {
172
+ static name = "template" as const
173
+
174
+ constraints = {
175
+ platform: {
176
+ any: true
177
+ }
178
+ } satisfies ModuleConstraints
179
+
180
+ async check(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {
181
+ const { content, src, dest } = this.params || ({} as TemplateModuleOptions)
182
+ if (!dest || typeof dest !== "string" || !dest.trim()) {
183
+ throw new Error("'dest' is required")
184
+ }
185
+ if (
186
+ (content == null || typeof content !== "string") &&
187
+ (src == null || typeof src !== "string")
188
+ ) {
189
+ throw new Error("one of 'content' or 'src' must be provided")
190
+ }
191
+ if (src != null && typeof src !== "string") {
192
+ throw new Error("'src' must be a string path")
193
+ }
194
+ }
195
+
196
+ async initialize(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
197
+ async cleanup(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
198
+
199
+ async execute(
200
+ ctx: Katmer.TaskContext<KatmerProvider>
201
+ ): Promise<TemplateModuleResult> {
202
+ const {
203
+ content,
204
+ src,
205
+ dest,
206
+ variables = {},
207
+ mode = "0644",
208
+ owner,
209
+ group,
210
+ dry_run = false,
211
+ diff_check = true
212
+ } = this.params
213
+
214
+ // Resolve template source (src or content)
215
+ let templateString: string
216
+ try {
217
+ if (typeof src === "string" && src.trim()) {
218
+ const abs = path.resolve(...[ctx.config.cwd!, src].filter(Boolean))
219
+ templateString = await fs.readFile(abs, "utf8")
220
+ } else {
221
+ templateString = String(content ?? "")
222
+ }
223
+ } catch (e: any) {
224
+ throw {
225
+ changed: false,
226
+ msg: `failed to read src: ${e?.message || String(e)}`
227
+ } as TemplateModuleResult
228
+ }
229
+
230
+ // Render locally using Twig renderer
231
+ let rendered: string
232
+ try {
233
+ rendered = await evalTemplate(
234
+ templateString,
235
+ toMerged(ctx.variables, variables)
236
+ )
237
+ } catch (e: any) {
238
+ throw {
239
+ changed: false,
240
+ msg: `template render failed: ${e?.message || String(e)}`
241
+ } as TemplateModuleResult
242
+ }
243
+
244
+ // Dry-run — just return the would-be content
245
+ if (dry_run) {
246
+ return {
247
+ changed: false,
248
+ dest,
249
+ mode: toModeString(mode),
250
+ owner,
251
+ group,
252
+ stdout: rendered
253
+ }
254
+ }
255
+
256
+ // Branch by provider type
257
+ if (ctx.provider instanceof SSHProvider) {
258
+ return await this.executeSSH(ctx as Katmer.TaskContext<SSHProvider>, {
259
+ rendered,
260
+ dest,
261
+ mode,
262
+ owner,
263
+ group,
264
+ diff_check
265
+ })
266
+ } else {
267
+ return await this.executeLocal(ctx as Katmer.TaskContext<LocalProvider>, {
268
+ rendered,
269
+ dest,
270
+ mode,
271
+ owner,
272
+ group,
273
+ diff_check
274
+ })
275
+ }
276
+ }
277
+
278
+ // ── SSH path (shell-based; mirrors your previous behavior) ───────────────────
279
+ private async executeSSH(
280
+ ctx: Katmer.TaskContext<SSHProvider>,
281
+ opts: {
282
+ rendered: string
283
+ dest: string
284
+ mode?: string | number
285
+ owner?: string
286
+ group?: string
287
+ diff_check: boolean
288
+ }
289
+ ): Promise<TemplateModuleResult> {
290
+ const { rendered, dest, mode, owner, group, diff_check } = opts
291
+ const q = (v: string) => JSON.stringify(v)
292
+
293
+ // Ensure parent directory
294
+ const mk = await ctx.exec(`mkdir -p -- "$(dirname ${q(dest)})"`)
295
+ if (mk.code !== 0) {
296
+ throw {
297
+ changed: false,
298
+ msg: mk.stderr || mk.stdout || "failed to ensure destination directory"
299
+ }
300
+ }
301
+
302
+ // Fail if dest is a directory
303
+ const isDir = await ctx.exec(`test -d ${q(dest)} >/dev/null 2>&1; echo $?`)
304
+ if (String(isDir.stdout).trim() === "0") {
305
+ throw {
306
+ changed: false,
307
+ msg: `'${dest}' is a directory`
308
+ } as TemplateModuleResult
309
+ }
310
+
311
+ // Diff check
312
+ if (diff_check) {
313
+ const exists = await ctx.exec(
314
+ `test -f ${q(dest)} >/dev/null 2>&1; echo $?`
315
+ )
316
+ if (String(exists.stdout).trim() === "0") {
317
+ const read = await ctx.exec(`cat ${q(dest)}`)
318
+ if (read.code === 0 && read.stdout === rendered) {
319
+ await applyMetaSSH(ctx, dest, mode, owner, group).catch(() => {})
320
+ return {
321
+ changed: false,
322
+ dest,
323
+ mode: toModeString(mode),
324
+ owner,
325
+ group
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ // Write via temp + install
332
+ const tmp = `/tmp/katmer-template-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
333
+ const writeTmp = await ctx.exec(`cat > ${q(tmp)} << "KATMER_EOF"
334
+ ${rendered}
335
+ KATMER_EOF`)
336
+ if (writeTmp.code !== 0) {
337
+ await ctx.exec(`rm -f -- ${q(tmp)}`).catch(() => {})
338
+ throw {
339
+ changed: false,
340
+ msg: writeTmp.stderr || writeTmp.stdout || "failed to write temp file"
341
+ }
342
+ }
343
+
344
+ const modeStr = toModeString(mode) ?? "0644"
345
+ const installCmd = `install -m ${modeStr} -D ${q(tmp)} ${q(dest)} || mv -f ${q(tmp)} ${q(dest)}`
346
+ const mv = await ctx.exec(installCmd)
347
+ await ctx.exec(`rm -f -- ${q(tmp)}`).catch(() => {})
348
+ if (mv.code !== 0) {
349
+ throw {
350
+ changed: false,
351
+ msg: mv.stderr || mv.stdout || "failed to install rendered file"
352
+ }
353
+ }
354
+
355
+ await applyMetaSSH(ctx, dest, mode, owner, group).catch(() => {})
356
+
357
+ return { changed: true, dest, mode: toModeString(mode), owner, group }
358
+ }
359
+
360
+ // ── Local path (pure Node.js; no shell) ──────────────────────────────────────
361
+ private async executeLocal(
362
+ ctx: Katmer.TaskContext<LocalProvider>,
363
+ opts: {
364
+ rendered: string
365
+ dest: string
366
+ mode?: string | number
367
+ owner?: string
368
+ group?: string
369
+ diff_check: boolean
370
+ }
371
+ ): Promise<TemplateModuleResult> {
372
+ const { rendered, dest, owner, group, diff_check } = opts
373
+ const mode = toOctal(opts.mode)
374
+ const parent = path.dirname(dest)
375
+ await fs.mkdir(parent, { recursive: true })
376
+
377
+ // Guard: dest is not a directory
378
+ try {
379
+ const st = await fs.stat(dest)
380
+ if (st.isDirectory()) {
381
+ throw {
382
+ changed: false,
383
+ msg: `'${dest}' is a directory`
384
+ } as TemplateModuleResult
385
+ }
386
+ } catch {
387
+ /* not existing is fine */
388
+ }
389
+
390
+ // Diff check
391
+ if (diff_check) {
392
+ try {
393
+ const current = await fs.readFile(dest, "utf8")
394
+ if (current === rendered) {
395
+ await applyMetaLocal(dest, mode, owner, group, ctx).catch(() => {})
396
+ return {
397
+ changed: false,
398
+ dest,
399
+ mode: toModeString(mode),
400
+ owner,
401
+ group
402
+ }
403
+ }
404
+ } catch {
405
+ /* file missing → proceed */
406
+ }
407
+ }
408
+
409
+ // Atomic-ish write: tmp → rename
410
+ const tmp = path.join(
411
+ parent,
412
+ `.katmer-template-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
413
+ )
414
+ await fs.writeFile(tmp, rendered, "utf8")
415
+ await fs.rename(tmp, dest)
416
+
417
+ await applyMetaLocal(dest, mode, owner, group, ctx).catch(() => {})
418
+
419
+ return { changed: true, dest, mode: toModeString(mode), owner, group }
420
+ }
421
+ }
422
+
423
+ // ── helpers ───────────────────────────────────────────────────────────────────
424
+ function toModeString(mode?: string | number) {
425
+ if (mode == null) return undefined
426
+ return typeof mode === "number" ? "0" + mode.toString(8) : mode
427
+ }
428
+
429
+ async function applyMetaSSH(
430
+ ctx: Katmer.TaskContext<SSHProvider>,
431
+ dest: string,
432
+ mode?: string | number,
433
+ owner?: string,
434
+ group?: string
435
+ ) {
436
+ const q = (v: string) => JSON.stringify(v)
437
+ if (mode != null) {
438
+ await ctx.exec(`chmod ${mode} -- ${q(dest)}`)
439
+ }
440
+ if (owner || group) {
441
+ const chownArg =
442
+ owner && group ? `${owner}:${group}`
443
+ : owner ? owner
444
+ : `:${group}`
445
+ await ctx.exec(`chown ${q(chownArg)} -- ${q(dest)}`)
446
+ }
447
+ }
448
+
449
+ async function applyMetaLocal(
450
+ dest: string,
451
+ mode?: string,
452
+ owner?: string,
453
+ group?: string,
454
+ ctx?: Katmer.TaskContext<LocalProvider>
455
+ ) {
456
+ if (mode != null) {
457
+ await fs.chmod(dest, parseInt(mode, 8))
458
+ }
459
+ if (owner != null || group != null) {
460
+ const st = await fs.stat(dest)
461
+ const uid = parseNumericId(owner) ?? st.uid
462
+ const gid = parseNumericId(group) ?? st.gid
463
+ if (uid !== st.uid || gid !== st.gid) {
464
+ try {
465
+ await fs.chown(dest, uid, gid)
466
+ } catch (e) {
467
+ ctx?.warn?.({
468
+ message: `local chown failed; owner/group must be numeric uid/gid. ${String(e)}`
469
+ })
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ function parseNumericId(v?: string): number | undefined {
476
+ if (!v) return undefined
477
+ return /^\d+$/.test(v) ? Number(v) : undefined
478
+ }