@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,243 @@
1
+ import fs from "fs-extra"
2
+ import git from "isomorphic-git"
3
+ import http from "isomorphic-git/http/node"
4
+ import {
5
+ type ModuleCommonReturn,
6
+ type ModuleConstraints
7
+ } from "../interfaces/module.interface"
8
+ import type { Katmer } from "../interfaces/task.interface"
9
+ import type { KatmerProvider } from "../interfaces/provider.interface"
10
+ import { SSHProvider } from "../providers/ssh/ssh.provider"
11
+ import { LocalProvider } from "../providers/local.provider"
12
+ import { KatmerModule } from "../module"
13
+
14
+ declare module "../interfaces/task.interface" {
15
+ export namespace Katmer {
16
+ export interface TaskActions {
17
+ git?: GitModuleOptions
18
+ }
19
+ }
20
+ }
21
+ /**
22
+ * Manage Git checkouts on the target machine.
23
+ *
24
+ * @remarks
25
+ * This module is inspired by **Ansible's `ansible.builtin.git`** module.
26
+ *
27
+ * Provider behavior:
28
+ * - **Local provider**:
29
+ * - Uses {@link https://isomorphic-git.org | isomorphic-git}
30
+ * - Does NOT require system `git`
31
+ * - Ideal for controller-side checkouts and reproducible environments
32
+ *
33
+ * - **SSH provider**:
34
+ * - Uses system-installed `git` on the target host
35
+ * - Supports Linux, macOS, and Windows (`git.exe`)
36
+ * - Honors `become` and shell handling via provider
37
+ *
38
+ * Idempotency:
39
+ * - `changed=false` when the repository is already at the desired revision
40
+ * - `changed=true` on clone, checkout, pull, or reset
41
+ *
42
+ * @examples
43
+ * Clone a repository:
44
+ * ```yaml
45
+ * - name: Clone repo
46
+ * git:
47
+ * repo: https://github.com/org/project.git
48
+ * dest: /opt/project
49
+ * ```
50
+ *
51
+ * Checkout a specific tag:
52
+ * ```yaml
53
+ * - name: Checkout release
54
+ * git:
55
+ * repo: https://github.com/org/project.git
56
+ * dest: /srv/app
57
+ * version: v1.4.2
58
+ * ```
59
+ *
60
+ * Force reset to main:
61
+ * ```yaml
62
+ * - name: Force sync
63
+ * git:
64
+ * repo: git@github.com:org/app.git
65
+ * dest: /srv/app
66
+ * version: main
67
+ * force: true
68
+ * ```
69
+ */
70
+ export class GitModule extends KatmerModule<
71
+ GitModuleOptions,
72
+ GitModuleResult,
73
+ KatmerProvider
74
+ > {
75
+ static name = "git" as const
76
+
77
+ constraints = {
78
+ platform: {
79
+ local: true,
80
+ any: { packages: ["git"] }
81
+ }
82
+ } satisfies ModuleConstraints
83
+
84
+ async check(): Promise<void> {
85
+ if (!this.params?.repo) throw new Error("git: 'repo' is required")
86
+ if (!this.params?.dest) throw new Error("git: 'dest' is required")
87
+ }
88
+
89
+ async initialize(): Promise<void> {}
90
+ async cleanup(): Promise<void> {}
91
+
92
+ async execute(ctx: Katmer.TaskContext): Promise<GitModuleResult> {
93
+ const p = normalizeOptions(this.params)
94
+
95
+ if (ctx.provider instanceof LocalProvider) {
96
+ return this.runLocal(ctx, p)
97
+ }
98
+
99
+ if (ctx.provider instanceof SSHProvider) {
100
+ return this.runSsh(ctx as Katmer.TaskContext<SSHProvider>, p)
101
+ }
102
+
103
+ return {
104
+ changed: false,
105
+ failed: true,
106
+ msg: `git: unsupported provider ${ctx.provider?.constructor?.name}`
107
+ }
108
+ }
109
+
110
+ // ────────────────────────────────────────────────────────────────────────────────
111
+ // Local (isomorphic-git)
112
+ // ────────────────────────────────────────────────────────────────────────────────
113
+
114
+ private async runLocal(
115
+ _ctx: Katmer.TaskContext<LocalProvider>,
116
+ p: NormalizedGitOptions
117
+ ): Promise<GitModuleResult> {
118
+ const exists = await fs.pathExists(p.dest)
119
+ let changed = false
120
+
121
+ if (!exists) {
122
+ await git.clone({
123
+ fs,
124
+ http,
125
+ dir: p.dest,
126
+ url: p.repo,
127
+ ref: p.version,
128
+ depth: p.depth
129
+ })
130
+ changed = true
131
+ } else {
132
+ const head = await git.resolveRef({ fs, dir: p.dest, ref: "HEAD" })
133
+ await git.fetch({ fs, http, dir: p.dest, ref: p.version })
134
+ await git.checkout({ fs, dir: p.dest, ref: p.version })
135
+ const newHead = await git.resolveRef({ fs, dir: p.dest, ref: "HEAD" })
136
+ if (head !== newHead) changed = true
137
+ }
138
+
139
+ return {
140
+ changed,
141
+ failed: false,
142
+ revision: await git.resolveRef({ fs, dir: p.dest, ref: "HEAD" })
143
+ }
144
+ }
145
+
146
+ // ────────────────────────────────────────────────────────────────────────────────
147
+ // SSH (system git)
148
+ // ────────────────────────────────────────────────────────────────────────────────
149
+
150
+ private async runSsh(
151
+ ctx: Katmer.TaskContext<SSHProvider>,
152
+ p: NormalizedGitOptions
153
+ ): Promise<GitModuleResult> {
154
+ const sh = ctx.provider.os.family === "windows" ? "" : "set -e; "
155
+ const q = (s: string) => JSON.stringify(s)
156
+
157
+ const exists = await ctx.execSafe(`${sh} test -d ${q(p.dest)}/.git`)
158
+
159
+ let changed = false
160
+
161
+ if (exists.code !== 0) {
162
+ await ctx.exec(
163
+ `${sh} git clone ${p.depth ? `--depth ${p.depth}` : ""} ${q(
164
+ p.repo
165
+ )} ${q(p.dest)}`
166
+ )
167
+ changed = true
168
+ }
169
+
170
+ const revBefore = await ctx.execSafe(
171
+ `${sh} git -C ${q(p.dest)} rev-parse HEAD`
172
+ )
173
+
174
+ if (p.force) {
175
+ await ctx.exec(
176
+ `${sh} git -C ${q(p.dest)} fetch --all && git -C ${q(
177
+ p.dest
178
+ )} reset --hard ${q(p.version || "")}`
179
+ )
180
+ changed = true
181
+ } else {
182
+ await ctx.exec(`${sh} git -C ${q(p.dest)} fetch`)
183
+ await ctx.exec(`${sh} git -C ${q(p.dest)} checkout ${q(p.version || "")}`)
184
+ }
185
+
186
+ const revAfter = await ctx.execSafe(
187
+ `${sh} git -C ${q(p.dest)} rev-parse HEAD`
188
+ )
189
+
190
+ if (revBefore.stdout !== revAfter.stdout) changed = true
191
+
192
+ return {
193
+ changed,
194
+ failed: false,
195
+ revision: revAfter.stdout?.trim()
196
+ }
197
+ }
198
+ }
199
+
200
+ /* ───────────────────────── Types ───────────────────────── */
201
+
202
+ /**
203
+ * Options for the {@link GitModule | `git`} module.
204
+ *
205
+ * @public
206
+ */
207
+ export interface GitModuleOptions {
208
+ /** Repository URL (HTTPS or SSH). */
209
+ repo: string
210
+ /** Destination directory on the target. */
211
+ dest: string
212
+ /** Branch, tag, or commit to checkout. */
213
+ version?: string
214
+ /** Force reset to the given version. */
215
+ force?: boolean
216
+ /** Create a shallow clone with the given depth. */
217
+ depth?: number
218
+ }
219
+
220
+ /**
221
+ * Result of the git operation.
222
+ *
223
+ * @public
224
+ */
225
+ export interface GitModuleResult extends ModuleCommonReturn {
226
+ /** Final commit hash after execution. */
227
+ revision?: string
228
+ }
229
+
230
+ /* ───────────────────────── Internals ───────────────────────── */
231
+
232
+ type NormalizedGitOptions = Required<Pick<GitModuleOptions, "repo" | "dest">> &
233
+ Omit<GitModuleOptions, "repo" | "dest">
234
+
235
+ function normalizeOptions(p: GitModuleOptions): NormalizedGitOptions {
236
+ return {
237
+ repo: p.repo,
238
+ dest: p.dest,
239
+ version: p.version ?? "HEAD",
240
+ force: p.force ?? false,
241
+ depth: p.depth
242
+ }
243
+ }
@@ -0,0 +1,213 @@
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
+ hostname?: HostnameModuleOptions
13
+ }
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Get or set the system hostname.
19
+ *
20
+ * @remarks
21
+ * - When no options are provided, the module gathers the current hostname facts and returns them as JSON.
22
+ * - When "name" is provided, the module sets the transient hostname (runtime) and optionally persists it to the appropriate config
23
+ * (e.g., /etc/hostname for most Linux distros or hostnamectl if available).
24
+ * - The module attempts to be idempotent: if the current hostname already matches the desired one, changed=false.
25
+ *
26
+ * @examples
27
+ * ```yaml
28
+ * - name: Get hostname facts
29
+ * hostname: {}
30
+ *
31
+ * - name: Set runtime hostname only
32
+ * hostname:
33
+ * name: "app-node-01"
34
+ *
35
+ * - name: Set and persist hostname
36
+ * hostname:
37
+ * name: "app-node-01"
38
+ * persist: true
39
+ * ```
40
+ */
41
+ export class HostnameModule extends KatmerModule<
42
+ HostnameModuleOptions,
43
+ HostnameModuleResult,
44
+ SSHProvider
45
+ > {
46
+ static name = "hostname" as const
47
+
48
+ constraints = {
49
+ platform: {
50
+ linux: true,
51
+ darwin: true,
52
+ windows: true
53
+ }
54
+ } satisfies ModuleConstraints
55
+
56
+ async check(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
57
+
58
+ async initialize(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
59
+
60
+ async cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
61
+
62
+ async execute(
63
+ ctx: Katmer.TaskContext<SSHProvider>
64
+ ): Promise<HostnameModuleResult> {
65
+ const { name, persist = false } = this.params
66
+
67
+ const osfam = ctx.provider.os.family
68
+ const run = async (cmd: string) => {
69
+ const r = await ctx.exec(cmd)
70
+ if (r.code !== 0) throw r
71
+ return r.stdout.trim()
72
+ }
73
+
74
+ // Gather current facts (single roundtrip)
75
+ const factsCmd = `
76
+ cur_short="$(hostname -s 2>/dev/null || true)"
77
+ cur_fqdn="$(hostname -f 2>/dev/null || hostname 2>/dev/null || true)"
78
+ cur_domain=""
79
+ # Derive domain from FQDN when possible
80
+ case "$cur_fqdn" in
81
+ *.*) cur_domain="\${cur_fqdn#*.}";;
82
+ *) cur_domain="";;
83
+ esac
84
+ printf '{"short":"%s","fqdn":"%s","domain":"%s"}' "$cur_short" "$cur_fqdn" "$cur_domain"
85
+ `.trim()
86
+
87
+ let changed = false
88
+ let current: HostnameFacts
89
+ try {
90
+ const out = await run(factsCmd)
91
+ current = JSON.parse(out) as HostnameFacts
92
+ } catch (e: any) {
93
+ // Fallback best-effort parse
94
+ const curShort = await run("hostname -s 2>/dev/null || true")
95
+ const curFqdn =
96
+ (await ctx.exec("hostname -f 2>/dev/null")).stdout.trim() ||
97
+ (await run("hostname 2>/dev/null || true"))
98
+ const curDomain =
99
+ curFqdn.includes(".") ? curFqdn.split(".").slice(1).join(".") : ""
100
+ current = { short: curShort, fqdn: curFqdn, domain: curDomain }
101
+ }
102
+
103
+ if (!name) {
104
+ // Read-only
105
+ return {
106
+ changed: false,
107
+ facts: current,
108
+ stdout: JSON.stringify(current)
109
+ }
110
+ }
111
+
112
+ // Set runtime hostname if needed
113
+ if (name !== current.short && name !== current.fqdn) {
114
+ // Prefer hostnamectl when available; otherwise use hostname command
115
+ const hasHostnamectl =
116
+ (
117
+ await ctx.exec("command -v hostnamectl >/dev/null 2>&1; echo $?")
118
+ ).stdout.trim() === "0"
119
+ const cmd =
120
+ hasHostnamectl ?
121
+ `hostnamectl set-hostname ${JSON.stringify(name)}`
122
+ : `hostname ${JSON.stringify(name)}`
123
+ const r = await ctx.exec(cmd)
124
+ if (r.code !== 0) {
125
+ throw {
126
+ changed: false,
127
+ msg: r.stderr || r.stdout || "failed to set hostname"
128
+ } satisfies HostnameModuleResult
129
+ }
130
+ changed = true
131
+ }
132
+
133
+ // Persist if requested (best-effort, Linux-focused)
134
+ if (persist) {
135
+ // If hostnamectl exists, it usually persists. Still ensure /etc/hostname matches for classic systems.
136
+ const etcHostname = "/etc/hostname"
137
+ const check = await ctx.exec(
138
+ `test -w ${JSON.stringify(etcHostname)}; echo $?`
139
+ )
140
+ if (check.stdout.trim() === "0") {
141
+ // Avoid extra change if content already matches
142
+ const read = await ctx.exec(
143
+ `cat ${JSON.stringify(etcHostname)} 2>/dev/null || echo ""`
144
+ )
145
+ if (read.stdout.trim() !== name.trim()) {
146
+ const write = await ctx.exec(
147
+ `printf %s ${JSON.stringify(name.trim())} > ${JSON.stringify(etcHostname)}`
148
+ )
149
+ if (write.code !== 0) {
150
+ throw {
151
+ changed,
152
+ msg: write.stderr || write.stdout || "failed to persist hostname"
153
+ } satisfies HostnameModuleResult
154
+ }
155
+ changed = true
156
+ }
157
+ }
158
+ }
159
+
160
+ // Re-gather to return final state
161
+ const finalOut = await run(factsCmd)
162
+ const facts = JSON.parse(finalOut) as HostnameFacts
163
+
164
+ return {
165
+ changed,
166
+ facts,
167
+ stdout: JSON.stringify(facts)
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Options for hostname module.
174
+ * @public
175
+ */
176
+ export interface HostnameModuleOptions {
177
+ /**
178
+ * Desired hostname. If omitted, module only gathers current hostname facts.
179
+ */
180
+ name?: string
181
+ /**
182
+ * Whether to persist the hostname to system config (e.g., /etc/hostname).
183
+ * @defaultValue false
184
+ */
185
+ persist?: boolean
186
+ }
187
+
188
+ /**
189
+ * Hostname facts returned by the module.
190
+ * @public
191
+ */
192
+ export interface HostnameFacts {
193
+ /**
194
+ * Short host name (without domain).
195
+ */
196
+ short: string
197
+ /**
198
+ * Fully-qualified domain name if resolvable, otherwise the plain hostname.
199
+ */
200
+ fqdn: string
201
+ /**
202
+ * Derived domain part from FQDN (empty if not applicable).
203
+ */
204
+ domain: string
205
+ }
206
+
207
+ /**
208
+ * Result of hostname module execution.
209
+ * @public
210
+ */
211
+ export interface HostnameModuleResult extends ModuleCommonReturn {
212
+ facts?: HostnameFacts
213
+ }