@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,420 @@
1
+ // ssh-provider.ts
2
+ import { NodeSSH, type Config } from "node-ssh"
3
+ import type { ClientChannel, ExecOptions } from "ssh2"
4
+ import {
5
+ KatmerProvider,
6
+ type OsInfo,
7
+ type ProviderOptions,
8
+ type SupportedShell
9
+ } from "../../interfaces/provider.interface"
10
+ import { makeLineEmitter } from "./ssh.utils"
11
+ import { pick } from "es-toolkit"
12
+ import { merge } from "es-toolkit/compat"
13
+
14
+ export interface SSHProviderOptions extends ProviderOptions {
15
+ hostname?: string
16
+ port?: number
17
+ username?: string
18
+ password?: string
19
+ private_key?: string
20
+ private_key_password?: string
21
+ shell?: SupportedShell
22
+ timeout?: number // ms
23
+ }
24
+
25
+ import { version } from "../../../package.json"
26
+ import { ProviderResponse } from "../provider_response"
27
+ import { normalizeArch, normalizeOs } from "../../utils/os.utils"
28
+
29
+ export interface CommandOptions extends ExecOptions {
30
+ cwd?: string
31
+ shell?: SupportedShell
32
+ timeout?: number
33
+ encoding?: BufferEncoding
34
+ onChannel?: (clientChannel: ClientChannel) => void
35
+ onStdout?: (line: string) => void
36
+ onStderr?: (line: string) => void
37
+ rewriteCommand?: (preparedCommand: string) => string
38
+ promptMarker?: string
39
+ interactivePassword?: string
40
+ hidePromptLine?: boolean
41
+ }
42
+
43
+ // Detect already-wrapped commands (avoid double wrap)
44
+ const SHELL_WRAPPED_RX =
45
+ /^\s*(?:ba?sh|zsh|dash|ksh|mksh|sh|fish)\s+-l?c\s+['"]/i
46
+ const PS_WRAPPED_RX = /^\s*powershell(?:\.exe)?\b.*?-Command\s+['"]/i
47
+ const CMD_WRAPPED_RX = /^\s*cmd(?:\.exe)?\s+\/(?:d\s+)?\/s\s+\/c\s+["']/i
48
+
49
+ // Conservative prompt patterns (generic)
50
+ const GENERIC_PROMPT_RX = /\b(password|passphrase)\s*(for [^\s:]+)?\s*:\s*$/i
51
+ const FAILURE_RX =
52
+ /\b(sorry,\s*try\s*again|incorrect\s*password|permission\s*denied)\b/i
53
+
54
+ export class SSHProvider extends KatmerProvider<SSHProviderOptions> {
55
+ static name = "ssh"
56
+ client: NodeSSH | null = null
57
+
58
+ async check(): Promise<void> {
59
+ if (!this.options.hostname)
60
+ throw new Error("SSHProvider requires a hostname.")
61
+ if (!this.options.username)
62
+ throw new Error("SSHProvider requires a username.")
63
+ if (!this.options.password && !this.options.private_key) {
64
+ throw new Error(
65
+ "SSHProvider requires either a password or a private_key."
66
+ )
67
+ }
68
+ }
69
+
70
+ async initialize(): Promise<void> {
71
+ this.client = new NodeSSH()
72
+ }
73
+
74
+ async connect(): Promise<void> {
75
+ if (!this.client) throw new Error("SSH client is not initialized.")
76
+ const sshConfig: Config = {
77
+ host: this.options.hostname!,
78
+ ident: `katmer_${version}`,
79
+ port: Number(this.options.port ?? 22),
80
+ username: this.options.username!
81
+ }
82
+ if (this.options.password)
83
+ sshConfig.password = String(this.options.password)
84
+ if (this.options.private_key)
85
+ sshConfig.privateKey = this.options.private_key
86
+ if (this.options.private_key_password)
87
+ sshConfig.passphrase = this.options.private_key_password
88
+
89
+ this.logger.debug(
90
+ `Connecting to "${this.options.name || this.options.hostname}"`
91
+ )
92
+ await this.client.connect(sshConfig)
93
+ this.connected = true
94
+ }
95
+
96
+ // Expose a safe exec builder (same as before)
97
+ executor(options: CommandOptions = {}) {
98
+ return async (
99
+ command: string,
100
+ execOpts: CommandOptions = {}
101
+ ): Promise<ProviderResponse> => {
102
+ const opts = merge(
103
+ {
104
+ // default shell may be decided dynamically
105
+ shell: (this.options.shell ??
106
+ this.defaultShell ??
107
+ "bash") as SupportedShell,
108
+ encoding: "utf-8",
109
+ interactivePassword: "",
110
+ hidePromptLine: true
111
+ },
112
+ this.options,
113
+ options,
114
+ execOpts
115
+ ) as CommandOptions
116
+
117
+ const prepareCommand = (command: string): string => {
118
+ const withCwd =
119
+ opts.cwd ? `cd ${JSON.stringify(opts.cwd)} && ${command}` : command
120
+ const shell = opts.shell
121
+
122
+ // If already shell-wrapped, don't touch
123
+ if (
124
+ shell === "none" ||
125
+ SHELL_WRAPPED_RX.test(withCwd) ||
126
+ PS_WRAPPED_RX.test(withCwd) ||
127
+ CMD_WRAPPED_RX.test(withCwd)
128
+ ) {
129
+ return withCwd
130
+ }
131
+
132
+ // POSIX family
133
+ if (
134
+ shell === "bash" ||
135
+ shell === "zsh" ||
136
+ shell === "sh" ||
137
+ shell === "dash" ||
138
+ shell === "ksh" ||
139
+ shell === "mksh" ||
140
+ shell === "fish"
141
+ ) {
142
+ const flag = shell === "bash" || shell === "zsh" ? "-lc" : "-c"
143
+ const singleQuoted = withCwd.replace(/'/g, "'\\''")
144
+ return `${shell} ${flag} '${singleQuoted}'`
145
+ }
146
+
147
+ // Windows shells
148
+ if (shell === "powershell") {
149
+ const ps = withCwd.replace(/'/g, "''")
150
+ return `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '${ps}'`
151
+ }
152
+ if (shell === "cmd") {
153
+ const dq = withCwd.replace(/"/g, '\\"')
154
+ return `cmd /d /s /c "${dq}"`
155
+ }
156
+
157
+ // Fallback: no wrapping
158
+ return withCwd
159
+ }
160
+
161
+ if (!this.client?.isConnected())
162
+ throw new Error("SSH client is not connected.")
163
+
164
+ const prepared = prepareCommand(command)
165
+ const finalCommand = opts.rewriteCommand?.(prepared) ?? prepared
166
+
167
+ return new Promise<ProviderResponse>((resolve, reject) => {
168
+ const connection = this.client!.connection!
169
+ let timeoutId: NodeJS.Timeout | null = null
170
+
171
+ const execOnlyOpts = pick(opts || {}, [
172
+ "allowHalfOpen",
173
+ "env",
174
+ "pty",
175
+ "x11"
176
+ ])
177
+ this.logger.trace({
178
+ msg: `[exec] ${finalCommand}`,
179
+ options: execOnlyOpts
180
+ })
181
+ connection.exec(finalCommand, execOnlyOpts, (err, channel) => {
182
+ if (err)
183
+ return reject(
184
+ new ProviderResponse({
185
+ command: finalCommand,
186
+ stderr: err.message,
187
+ code: 1
188
+ })
189
+ )
190
+
191
+ if (opts.timeout && opts.timeout > 0) {
192
+ timeoutId = setTimeout(() => {
193
+ try {
194
+ channel.close()
195
+ } catch {}
196
+ reject(
197
+ new ProviderResponse({
198
+ command: finalCommand,
199
+ stderr: `Command timed out after ${opts.timeout}ms`,
200
+ code: 1
201
+ })
202
+ )
203
+ }, opts.timeout)
204
+ }
205
+
206
+ opts.onChannel?.(channel)
207
+
208
+ const stdoutLines: string[] = []
209
+ const stderrLines: string[] = []
210
+ const emitStdout = makeLineEmitter((line) => {
211
+ if (
212
+ !(
213
+ opts.hidePromptLine &&
214
+ opts.promptMarker &&
215
+ line.includes(opts.promptMarker)
216
+ )
217
+ ) {
218
+ opts.onStdout?.(line)
219
+ stdoutLines.push(line)
220
+ }
221
+ })
222
+ const emitStderr = makeLineEmitter((line) => {
223
+ if (
224
+ !(
225
+ opts.hidePromptLine &&
226
+ opts.promptMarker &&
227
+ line.includes(opts.promptMarker)
228
+ )
229
+ ) {
230
+ opts.onStderr?.(line)
231
+ stderrLines.push(line)
232
+ }
233
+ })
234
+
235
+ let pwSent = false
236
+ let genericPwSent = false
237
+ let authDenied = false
238
+ let buffer = ""
239
+
240
+ const handlePrompts = (text: string) => {
241
+ buffer += text
242
+
243
+ const hasPrompt =
244
+ opts.promptMarker && text.includes(opts.promptMarker)
245
+
246
+ if (
247
+ !pwSent &&
248
+ opts.promptMarker &&
249
+ buffer.includes(opts.promptMarker)
250
+ ) {
251
+ channel.stdin.write(opts.interactivePassword + "\n")
252
+ pwSent = true
253
+ }
254
+
255
+ if (!genericPwSent && GENERIC_PROMPT_RX.test(buffer)) {
256
+ channel.stdin.write(opts.interactivePassword + "\n")
257
+ genericPwSent = true
258
+ }
259
+
260
+ if (FAILURE_RX.test(buffer)) authDenied = true
261
+ if (buffer.length > 4096) buffer = buffer.slice(-2048)
262
+ return hasPrompt
263
+ }
264
+
265
+ channel.on("data", (chunk: Buffer) => {
266
+ const text = chunk.toString(opts.encoding)
267
+ handlePrompts(text)
268
+ emitStdout(text)
269
+ })
270
+
271
+ channel.stderr.on("data", (chunk: Buffer) => {
272
+ const text = chunk.toString(opts.encoding)
273
+ if (!handlePrompts(text)) {
274
+ emitStderr(text)
275
+ }
276
+ })
277
+
278
+ let code: number | null = null
279
+ channel.on("exit", (c: any) => {
280
+ code = c ?? null
281
+ })
282
+
283
+ channel.on("close", () => {
284
+ if (timeoutId) clearTimeout(timeoutId)
285
+ const result = new ProviderResponse({
286
+ command: finalCommand,
287
+ stdout: stdoutLines.join("\n").trim(),
288
+ stderr: stderrLines.join("\n").trim(),
289
+ code: code ?? (authDenied ? 1 : -1)
290
+ })
291
+ if (result.code === 0) {
292
+ resolve(result)
293
+ } else {
294
+ reject(result)
295
+ }
296
+ })
297
+
298
+ channel.on("error", (e: any) => {
299
+ if (timeoutId) clearTimeout(timeoutId)
300
+ reject(
301
+ new ProviderResponse({
302
+ command: finalCommand,
303
+ stdout: stdoutLines.join("\n").trim(),
304
+ stderr: String(e?.message ?? e),
305
+ code: 1
306
+ })
307
+ )
308
+ })
309
+ })
310
+ })
311
+ }
312
+ }
313
+
314
+ async destroy(): Promise<void> {
315
+ if (this.client?.isConnected()) {
316
+ this.client.connection?.end()
317
+ }
318
+ this.connected = false
319
+ }
320
+
321
+ async cleanup(): Promise<void> {
322
+ this.client?.dispose()
323
+ this.client = null
324
+ this.initialized = false
325
+ }
326
+
327
+ /** Probe remote OS/arch as soon as we connect; stores results in `this.osInfo`. */
328
+ async getOsInfo(): Promise<OsInfo> {
329
+ const execRaw = this.executor({ shell: "none", timeout: 5000 })
330
+
331
+ // POSIX probe (uname + /etc/os-release)
332
+ const posixScript =
333
+ 'OS="$(uname -s 2>/dev/null || true)"; ARCH="$(uname -m 2>/dev/null || true)"; F=""; ' +
334
+ '[ -r /etc/os-release ] && F=/etc/os-release; [ -z "$F" ] && [ -r /usr/lib/os-release ] && F=/usr/lib/os-release; ' +
335
+ 'ID=""; VERSION_ID=""; PRETTY_NAME=""; [ -n "$F" ] && . "$F"; ' +
336
+ 'printf "__os=%s\\n__arch=%s\\n__id=%s\\n__ver=%s\\n__pretty=%s\\n" "$OS" "$ARCH" "$ID" "$VERSION_ID" "$PRETTY_NAME"'
337
+
338
+ let out: ProviderResponse | null = null
339
+ try {
340
+ out = await execRaw(`sh -c '${posixScript.replace(/'/g, "'\\''")}'`)
341
+ } catch {
342
+ // try bash if /bin/sh is missing (rare)
343
+ try {
344
+ out = await execRaw(`bash -lc '${posixScript.replace(/'/g, "'\\''")}'`)
345
+ } catch {
346
+ out = null
347
+ }
348
+ }
349
+
350
+ if (out && out.code === 0 && out.stdout) {
351
+ const kv = parseTagged(out.stdout)
352
+ const kernel = (kv.__os || "").trim()
353
+ const archRaw = (kv.__arch || "").trim()
354
+ const fam = normalizeOs(kernel)
355
+ const res: OsInfo = {
356
+ family: fam,
357
+ arch: normalizeArch(archRaw),
358
+ kernel,
359
+ distroId: fam === "windows" ? "windows" : kv.__id || undefined,
360
+ versionId: fam === "windows" ? undefined : kv.__ver || undefined,
361
+ prettyName: kv.__pretty || undefined,
362
+ source: "posix"
363
+ }
364
+ this.os = res
365
+ return res
366
+ }
367
+
368
+ // PowerShell probe (Windows)
369
+ const ps = [
370
+ "$arch=$env:PROCESSOR_ARCHITECTURE;",
371
+ "$osCaption=(Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption;",
372
+ "if(-not $osCaption){$osCaption=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -ErrorAction SilentlyContinue).ProductName}",
373
+ "$ver=(Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Version;",
374
+ "$obj=[ordered]@{os='Windows';arch=$arch;id='windows';version=$ver;pretty=$osCaption};",
375
+ "$obj|ConvertTo-Json -Compress"
376
+ ].join(" ")
377
+
378
+ try {
379
+ const r = await execRaw(
380
+ `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '${ps.replace(
381
+ /'/g,
382
+ "''"
383
+ )}'`
384
+ )
385
+ const data = JSON.parse(r.stdout || "{}")
386
+ const res: OsInfo = {
387
+ family: "windows",
388
+ arch: normalizeArch(String(data.arch || "")),
389
+ kernel: "Windows",
390
+ distroId: "windows",
391
+ versionId: data.version || undefined,
392
+ prettyName: data.pretty || undefined,
393
+ source: "powershell"
394
+ }
395
+ this.os = res
396
+ return res
397
+ } catch {
398
+ // fallthrough
399
+ }
400
+
401
+ const res: OsInfo = {
402
+ family: "unknown",
403
+ arch: "unknown",
404
+ source: "unknown"
405
+ }
406
+ this.os = res
407
+ return res
408
+ }
409
+ }
410
+
411
+ /* ───────────────────────── utilities ───────────────────────── */
412
+
413
+ function parseTagged(s: string): Record<string, string> {
414
+ const out: Record<string, string> = {}
415
+ for (const line of String(s).split(/\r?\n/)) {
416
+ const m = line.match(/^(__[a-z]+)=(.*)$/)
417
+ if (m) out[m[1]] = m[2]
418
+ }
419
+ return out
420
+ }
@@ -0,0 +1,31 @@
1
+ import { SSHProvider } from "./ssh.provider"
2
+ import { baseName, targetDir } from "../../utils/path.utils"
3
+ import { toOctal } from "../../utils/number.utils"
4
+
5
+ /**
6
+ * Split string into lines while keeping separators.
7
+ */
8
+ function splitLinesIncludeSeparators(str: string): string[] {
9
+ const linesWithSeparators: string[] = []
10
+ const parts = str.split(/(\r\n|\r+|\n)/)
11
+ for (let i = 0; i < Math.ceil(parts.length / 2); i++) {
12
+ linesWithSeparators.push(parts[2 * i] + (parts[2 * i + 1] ?? ""))
13
+ }
14
+ return linesWithSeparators
15
+ }
16
+
17
+ /**
18
+ * Returns a handler that buffers chunks and emits complete lines.
19
+ */
20
+ export function makeLineEmitter(cb: (line: string, buffer: string) => void) {
21
+ let buffer = ""
22
+ return (chunk: string) => {
23
+ buffer += chunk
24
+ const lines = splitLinesIncludeSeparators(buffer)
25
+ const last = lines[lines.length - 1]
26
+ const hasSep = last.endsWith("\n") || last.endsWith("\r")
27
+ const emit = hasSep ? lines : lines.slice(0, -1)
28
+ buffer = hasSep ? "" : last
29
+ for (const line of emit) cb(line, buffer)
30
+ }
31
+ }