@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,336 @@
1
+ // local-provider.ts
2
+ import {
3
+ KatmerProvider,
4
+ type OsInfo,
5
+ type ProviderOptions,
6
+ type SupportedShell
7
+ } from "../interfaces/provider.interface"
8
+ import * as child_process from "node:child_process"
9
+ import type { ChildProcess } from "node:child_process"
10
+ import { makeLineEmitter } from "./ssh/ssh.utils"
11
+ import { merge } from "es-toolkit/compat"
12
+ import { ProviderResponse } from "./provider_response"
13
+
14
+ // NEW: extra imports for OS detection
15
+ import * as os from "node:os"
16
+ import * as fs from "node:fs/promises"
17
+ import { normalizeArch, normalizeOs } from "../utils/os.utils"
18
+
19
+ export interface LocalProviderOptions extends ProviderOptions {}
20
+
21
+ export interface CommandOptions {
22
+ cwd?: string
23
+ shell?: SupportedShell
24
+ timeout?: number
25
+ env?: Record<string, string>
26
+ encoding?: BufferEncoding
27
+ onChannel?: (clientChannel: ChildProcess) => void
28
+ onStdout?: (line: string) => void
29
+ onStderr?: (line: string) => void
30
+
31
+ rewriteCommand?: (preparedCommand: string) => string
32
+ promptMarker?: string
33
+ interactivePassword?: string
34
+ hidePromptLine?: boolean
35
+ }
36
+
37
+ // Conservative prompt patterns (generic)
38
+ const GENERIC_PROMPT_RX = /\b(password|passphrase)\s*(for [^\s:]+)?\s*:\s*$/i
39
+ const FAILURE_RX =
40
+ /\b(sorry,\s*try\s*again|incorrect\s*password|permission\s*denied)\b/i
41
+
42
+ export class LocalProvider extends KatmerProvider<LocalProviderOptions> {
43
+ static name = "local"
44
+
45
+ async check(): Promise<void> {}
46
+ async initialize(): Promise<void> {}
47
+ async connect(): Promise<void> {}
48
+
49
+ /**
50
+ * Detect controller OS/arch quickly using Node APIs with light fallbacks.
51
+ * Populates `this.os` and returns the same object.
52
+ */
53
+ async getOsInfo(): Promise<OsInfo> {
54
+ // Base facts from Node
55
+ const kernelRaw = os.type() || "" // 'Linux' | 'Darwin' | 'Windows_NT'
56
+ const family = normalizeOs(kernelRaw)
57
+ // Prefer PROCESSOR_ARCHITECTURE on Windows; otherwise process.arch
58
+ const archRaw =
59
+ process.platform === "win32" ?
60
+ process.env.PROCESSOR_ARCHITECTURE || process.arch
61
+ : process.arch
62
+ const info: OsInfo = {
63
+ family,
64
+ arch: normalizeArch(String(archRaw || "")),
65
+ kernel: kernelRaw,
66
+ source: "posix"
67
+ }
68
+
69
+ try {
70
+ if (family === "linux") {
71
+ const file =
72
+ (await fileIfExists("/etc/os-release")) ??
73
+ (await fileIfExists("/usr/lib/os-release"))
74
+ if (file) {
75
+ const env = parseOsRelease(await fs.readFile(file, "utf8"))
76
+ info.distroId = env.ID ?? info.distroId
77
+ info.versionId = env.VERSION_ID ?? info.versionId
78
+ info.prettyName = env.PRETTY_NAME ?? info.prettyName
79
+ }
80
+ } else if (family === "darwin") {
81
+ // sw_vers is standard on macOS
82
+ const [productName, productVersion] = await Promise.all([
83
+ tryExec("sw_vers", ["-productName"]),
84
+ tryExec("sw_vers", ["-productVersion"])
85
+ ])
86
+ if (productName || productVersion) {
87
+ info.distroId = "macos"
88
+ info.versionId = productVersion?.trim() || undefined
89
+ info.prettyName = [productName?.trim(), productVersion?.trim()]
90
+ .filter(Boolean)
91
+ .join(" ")
92
+ }
93
+ } else if (family === "windows") {
94
+ // Best-effort pretty/version via PowerShell (if available)
95
+ const ps = await tryExec("powershell", [
96
+ "-NoProfile",
97
+ "-NonInteractive",
98
+ "-ExecutionPolicy",
99
+ "Bypass",
100
+ "-Command",
101
+ [
102
+ "$cap=(Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption;",
103
+ "if(-not $cap){$cap=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -ErrorAction SilentlyContinue).ProductName};",
104
+ "$ver=(Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Version;",
105
+ "[Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8;",
106
+ "Write-Output ($cap);",
107
+ "Write-Output ($ver)"
108
+ ].join(" ")
109
+ ])
110
+ if (ps) {
111
+ const lines = ps.split(/\r?\n/)
112
+ info.distroId = "windows"
113
+ info.prettyName = (lines[0] || "").trim() || info.prettyName
114
+ info.versionId = (lines[1] || "").trim() || info.versionId
115
+ info.source = "powershell"
116
+ } else {
117
+ info.distroId = "windows"
118
+ info.source = "powershell"
119
+ }
120
+ }
121
+ } catch {
122
+ // keep minimal info; we already have family/arch/kernel
123
+ }
124
+
125
+ this.os = info
126
+ return info
127
+ }
128
+
129
+ executor(options: CommandOptions = {}) {
130
+ return async (
131
+ command: string,
132
+ execOpts: CommandOptions = {}
133
+ ): Promise<ProviderResponse> => {
134
+ const opts = merge(
135
+ {
136
+ encoding: "utf-8",
137
+ interactivePassword: "",
138
+ hidePromptLine: true
139
+ },
140
+ this.options,
141
+ options,
142
+ execOpts
143
+ ) as CommandOptions
144
+
145
+ const prepareCommand = (command: string): string => {
146
+ const withCwd =
147
+ opts.cwd ? `cd ${JSON.stringify(opts.cwd)} && ${command}` : command
148
+ return `${withCwd.replace(/'/g, "'\\''")}`
149
+ }
150
+
151
+ const prepared = prepareCommand(command)
152
+ const finalCommand = opts.rewriteCommand?.(prepared) ?? prepared
153
+
154
+ return new Promise<ProviderResponse>(async (resolve, reject) => {
155
+ let timeoutId: NodeJS.Timeout | null = null
156
+
157
+ this.logger.trace(`[exec] %s`, finalCommand)
158
+
159
+ // FIX: if opts.shell === "none", do NOT pass the literal string to Node.
160
+ // Use platform default shell (true) instead.
161
+ const shellOpt =
162
+ opts.shell && opts.shell !== "none" ? (opts.shell as any) : true
163
+
164
+ const channel = child_process.spawn(finalCommand, {
165
+ shell: shellOpt,
166
+ stdio: "pipe",
167
+ env: opts.env,
168
+ cwd: opts.cwd
169
+ })
170
+
171
+ if (opts.timeout && opts.timeout > 0) {
172
+ timeoutId = setTimeout(() => {
173
+ try {
174
+ channel.kill()
175
+ } catch {}
176
+ reject(
177
+ new ProviderResponse({
178
+ command: finalCommand,
179
+ stderr: `Command timed out after ${opts.timeout}ms`,
180
+ code: 1
181
+ })
182
+ )
183
+ }, opts.timeout)
184
+ }
185
+
186
+ opts.onChannel?.(channel)
187
+
188
+ const stdoutLines: string[] = []
189
+ const stderrLines: string[] = []
190
+ const emitStdout = makeLineEmitter((line) => {
191
+ if (
192
+ !(
193
+ opts.hidePromptLine &&
194
+ opts.promptMarker &&
195
+ line.includes(opts.promptMarker)
196
+ )
197
+ ) {
198
+ opts.onStdout?.(line)
199
+ stdoutLines.push(line)
200
+ }
201
+ })
202
+ const emitStderr = makeLineEmitter((line) => {
203
+ if (
204
+ !(
205
+ opts.hidePromptLine &&
206
+ opts.promptMarker &&
207
+ line.includes(opts.promptMarker)
208
+ )
209
+ ) {
210
+ opts.onStderr?.(line)
211
+ stderrLines.push(line)
212
+ }
213
+ })
214
+
215
+ let pwSent = false
216
+ let genericPwSent = false
217
+ let authDenied = false
218
+ let buffer = ""
219
+
220
+ const handlePrompts = (text: string) => {
221
+ buffer += text
222
+
223
+ if (
224
+ !pwSent &&
225
+ opts.promptMarker &&
226
+ buffer.includes(opts.promptMarker)
227
+ ) {
228
+ channel.stdin?.write(opts.interactivePassword + "\n")
229
+ pwSent = true
230
+ }
231
+
232
+ if (!genericPwSent && GENERIC_PROMPT_RX.test(buffer)) {
233
+ channel.stdin?.write(opts.interactivePassword + "\n")
234
+ genericPwSent = true
235
+ }
236
+
237
+ if (FAILURE_RX.test(buffer)) authDenied = true
238
+ if (buffer.length > 4096) buffer = buffer.slice(-2048)
239
+ }
240
+
241
+ channel.stdout?.on("data", (chunk: Buffer) => {
242
+ const text = chunk.toString(opts.encoding)
243
+ handlePrompts(text)
244
+ emitStdout(text)
245
+ })
246
+
247
+ channel.stderr?.on("data", (chunk: Buffer) => {
248
+ const text = chunk.toString(opts.encoding)
249
+ handlePrompts(text)
250
+ emitStderr(text)
251
+ })
252
+
253
+ let code: number | null = null
254
+ channel.on("exit", (c: any) => {
255
+ code = c ?? null
256
+ })
257
+
258
+ channel.on("close", () => {
259
+ if (timeoutId) clearTimeout(timeoutId)
260
+ const result = new ProviderResponse({
261
+ command: finalCommand,
262
+ stdout: stdoutLines.join("\n"),
263
+ stderr: stderrLines.join("\n"),
264
+ code: code ?? (authDenied ? 1 : -1)
265
+ })
266
+ if (result.code === 0) {
267
+ resolve(result)
268
+ } else {
269
+ reject(result as unknown as Error)
270
+ }
271
+ })
272
+
273
+ channel.on("error", (e: any) => {
274
+ if (timeoutId) clearTimeout(timeoutId)
275
+ reject(
276
+ new ProviderResponse({
277
+ command: finalCommand,
278
+ stdout: stdoutLines.join("\n"),
279
+ stderr: String(e?.message ?? e),
280
+ code: 1
281
+ })
282
+ )
283
+ })
284
+ })
285
+ }
286
+ }
287
+
288
+ async destroy(): Promise<void> {}
289
+ async cleanup(): Promise<void> {}
290
+ }
291
+
292
+ /* ───────────────────────── helpers ───────────────────────── */
293
+
294
+ // read first existing file path (returns path or null)
295
+ async function fileIfExists(p: string): Promise<string | null> {
296
+ try {
297
+ await fs.access(p)
298
+ return p
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ function parseOsRelease(src: string): Record<string, string> {
305
+ const out: Record<string, string> = {}
306
+ for (const line of src.split(/\r?\n/)) {
307
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/)
308
+ if (!m) continue
309
+ const k = m[1]
310
+ let v = m[2]
311
+ // strip quotes if present
312
+ if (
313
+ (v.startsWith('"') && v.endsWith('"')) ||
314
+ (v.startsWith("'") && v.endsWith("'"))
315
+ ) {
316
+ v = v.slice(1, -1)
317
+ }
318
+ out[k] = v
319
+ }
320
+ return out
321
+ }
322
+
323
+ async function tryExec(cmd: string, args: string[]): Promise<string | null> {
324
+ return await new Promise((resolve) => {
325
+ const child = child_process.execFile(
326
+ cmd,
327
+ args,
328
+ { windowsHide: true },
329
+ (err, stdout) => {
330
+ if (err) return resolve(null)
331
+ resolve(stdout?.toString() ?? "")
332
+ }
333
+ )
334
+ child.on("error", () => resolve(null))
335
+ })
336
+ }
@@ -0,0 +1,20 @@
1
+ export interface ExecutionResult {
2
+ command: string
3
+ code: number
4
+ stdout?: string
5
+ stderr?: string
6
+ }
7
+
8
+ export class ProviderResponse implements ExecutionResult {
9
+ constructor(opts: ExecutionResult) {
10
+ Object.assign(this, opts)
11
+ }
12
+ toString() {
13
+ return (this.stderr || this.stdout || "")?.trim()
14
+ }
15
+
16
+ code!: number
17
+ command!: string
18
+ stderr!: string
19
+ stdout!: string
20
+ }