@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,342 @@
1
+ import { type ModuleConstraints } from "../../interfaces/module.interface"
2
+ import type { SSHProvider } from "../../providers/ssh/ssh.provider"
3
+ import type { Katmer } from "../../interfaces/task.interface"
4
+ import { toOctal } from "../../utils/number.utils"
5
+ import type {
6
+ HttpModuleOptions,
7
+ HttpOutput,
8
+ HttpModuleResult
9
+ } from "./http.module"
10
+ import { quote } from "../../utils/string.utils"
11
+ import type { KatmerProvider } from "../../interfaces/provider.interface"
12
+ import { parseHeaderString } from "../../utils/http.utils"
13
+ import { KatmerModule } from "../../module"
14
+
15
+ export class HttpModule extends KatmerModule<
16
+ HttpModuleOptions,
17
+ HttpModuleResult,
18
+ SSHProvider
19
+ > {
20
+ static name = "http" as const
21
+
22
+ constraints = {
23
+ platform: {
24
+ linux: { packages: ["curl"] },
25
+ darwin: { packages: ["curl"] }
26
+ }
27
+ } satisfies ModuleConstraints
28
+
29
+ /**
30
+ * Validate environment and parameters before execution.
31
+ *
32
+ * @throws Error if `url` is missing.
33
+ */
34
+ async check(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
35
+ const { url } = this.params || ({} as HttpModuleOptions)
36
+ if (!url || typeof url !== "string" || !url.trim()) {
37
+ throw new Error("'url' is required")
38
+ }
39
+
40
+ if (!URL.canParse(url)) {
41
+ throw new Error("'url' is not a valid URL")
42
+ }
43
+ }
44
+
45
+ async initialize(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
46
+ async cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
47
+
48
+ /**
49
+ * Execute the http command with given options.
50
+ *
51
+ * @param ctx - Task context with remote executor.
52
+ * @returns The {@link HttpModuleResult}.
53
+ *
54
+ * @throws {@link HttpModuleResult} (thrown as an error object) when
55
+ * {@link HttpModuleOptions.fail_on_http_error | fail_on_http_error} is true and http exits non-zero.
56
+ */
57
+ async execute(
58
+ ctx: Katmer.TaskContext<SSHProvider>
59
+ ): Promise<HttpModuleResult> {
60
+ const {
61
+ url,
62
+ method = "GET",
63
+ headers = {},
64
+ query,
65
+ body,
66
+ bodyFile,
67
+ auth,
68
+ timeout = 30,
69
+ follow_redirects = true,
70
+ validate_certs = true,
71
+ output,
72
+ save_headers_to,
73
+ fail_on_http_error = true,
74
+ retry,
75
+ extra_args = [],
76
+ mode,
77
+ owner,
78
+ group
79
+ } = this.params
80
+
81
+ const parsedUrl = new URL(url)
82
+ for (const [key, val] of Object.entries(query || {})) {
83
+ parsedUrl.searchParams.set(key, String(val))
84
+ }
85
+ const finalUrl = parsedUrl.toString()
86
+ const urlArg = quote(finalUrl)
87
+
88
+ // Header args
89
+ const headerArgs: string[] = []
90
+ for (const [k, v] of Object.entries(headers)) {
91
+ headerArgs.push(`-H ${quote(`${k}: ${v}`)}`)
92
+ }
93
+
94
+ // Auth
95
+ if (auth?.type === "basic") {
96
+ headerArgs.push(`-u ${quote(`${auth.username}:${auth.password}`)}`)
97
+ } else if (auth?.type === "bearer") {
98
+ headerArgs.push(`-H ${quote(`Authorization: Bearer ${auth.token}`)}`)
99
+ }
100
+
101
+ // Body
102
+ let dataArg: string | undefined
103
+ if (bodyFile) {
104
+ dataArg = `--data-binary @${quote(bodyFile)}`
105
+ } else if (typeof body !== "undefined") {
106
+ const { dataArg: d, headerArgs: extraHdrs } = ensureJsonBody(
107
+ body,
108
+ headers
109
+ )
110
+ dataArg = d
111
+ headerArgs.push(...extraHdrs)
112
+ }
113
+
114
+ // Flags
115
+ const flags = [
116
+ "-sS",
117
+ follow_redirects ? "-L" : "",
118
+ validate_certs ? "" : "--insecure",
119
+ timeout ? `--max-time ${timeout}` : "",
120
+ fail_on_http_error ? "--fail-with-body" : "",
121
+ retry?.tries ? `--retry ${retry.tries}` : "",
122
+ retry?.delay ? `--retry-delay ${retry.delay}` : "",
123
+ retry?.max_time ? `--retry-max-time ${retry.max_time}` : ""
124
+ ]
125
+ .filter(Boolean)
126
+ .join(" ")
127
+
128
+ // Save headers
129
+ const headerOutArg = save_headers_to ? `-D ${quote(save_headers_to)}` : ""
130
+
131
+ // Output normalization
132
+ const normalizedOutput: HttpOutput =
133
+ typeof output === "string" ? { toFile: output } : output || {}
134
+ const toFile = normalizedOutput.toFile
135
+ const captureBody = !!normalizedOutput.captureBody
136
+
137
+ // Temp file for body when writing to file
138
+ const tmpFile = `/tmp/katmer-http-${Date.now()}.body.tmp`
139
+ const outArg = toFile ? `-o ${quote(tmpFile)}` : ""
140
+
141
+ // Method argument
142
+ const methodArg =
143
+ method === "GET" && !body && !bodyFile ? ""
144
+ : method === "HEAD" && toFile ? "-X HEAD"
145
+ : method === "HEAD" ? "-I"
146
+ : `-X ${method}`
147
+
148
+ // Build command
149
+ const cmd = [
150
+ "curl",
151
+ flags,
152
+ methodArg,
153
+ headerOutArg,
154
+ headerArgs.join(" "),
155
+ dataArg || "",
156
+ ...extra_args
157
+ ]
158
+ .filter(Boolean)
159
+ .join(" ")
160
+
161
+ const execCmd = [cmd, urlArg, outArg].filter(Boolean).join(" ")
162
+
163
+ // Execute
164
+ const res = await ctx.exec(execCmd)
165
+
166
+ // Read response status if headers saved
167
+ let status: number | undefined
168
+ let headersText: string | undefined
169
+ if (save_headers_to) {
170
+ const stat = await ctx.exec(`test -f ${quote(save_headers_to)}; echo $?`)
171
+ if (String(stat.stdout).trim() === "0") {
172
+ const hdr = await ctx.exec(`cat ${quote(save_headers_to)}`)
173
+ headersText = hdr.stdout
174
+ const m = headersText.match(/HTTP\/\d+\.\d+\s+(\d{3})/)
175
+ if (m) status = Number(m[1])
176
+ }
177
+ }
178
+ const parsedHeaders = parseHeaderString(headersText)
179
+
180
+ // Failures: throw result object
181
+ if (res.code !== 0 && fail_on_http_error) {
182
+ throw {
183
+ url: parsedUrl,
184
+ changed: false,
185
+ status,
186
+ headers: parsedHeaders,
187
+ body: undefined,
188
+ dest: undefined,
189
+ msg: res.stderr || res.stdout || "http request failed"
190
+ } satisfies HttpModuleResult
191
+ }
192
+
193
+ // Handle outputs
194
+ let bodyText: string | undefined
195
+ let dest: string | undefined
196
+
197
+ if (toFile) {
198
+ // Ensure destination directory exists
199
+ const parent =
200
+ toFile.substring(0, Math.max(0, toFile.lastIndexOf("/"))) || "/"
201
+ await ctx.exec(`mkdir -p -- ${quote(parent)}`)
202
+
203
+ // Ensure tmp exists
204
+ const tmpExists = await ctx.exec(`test -f ${quote(tmpFile)}; echo $?`)
205
+ if (String(tmpExists.stdout).trim() !== "0") {
206
+ throw {
207
+ url: parsedUrl,
208
+ changed: false,
209
+ status,
210
+ headers: parsedHeaders,
211
+ body: undefined,
212
+ dest: undefined,
213
+ msg: "http request produced no body"
214
+ } satisfies HttpModuleResult
215
+ }
216
+
217
+ // Move into place (always changed on success)
218
+ const modeStr = toOctal(mode) ?? "0644"
219
+ const installCmd = `install -m ${modeStr} -D ${quote(tmpFile)} ${quote(
220
+ toFile
221
+ )} || mv -f ${quote(tmpFile)} ${quote(toFile)}`
222
+ const mv = await ctx.exec(installCmd)
223
+ if (mv.code !== 0) {
224
+ await ctx.exec(`rm -f -- ${quote(tmpFile)}`).catch(() => {})
225
+ throw {
226
+ url: parsedUrl,
227
+ changed: false,
228
+ status,
229
+ headers: parsedHeaders,
230
+ body: undefined,
231
+ dest: undefined,
232
+ msg: mv.stderr || mv.stdout || "failed to move downloaded body"
233
+ } satisfies HttpModuleResult
234
+ }
235
+
236
+ // perms
237
+ if (mode != null) {
238
+ const m = toOctal(mode)
239
+ await ctx.exec(`chmod ${m} -- ${quote(toFile)}`).catch(() => {})
240
+ }
241
+ if (owner || group) {
242
+ const chownArg =
243
+ owner && group ? `${owner}:${group}`
244
+ : owner ? owner
245
+ : `:${group}`
246
+ await ctx.exec(`chown ${chownArg} -- ${quote(toFile)}`).catch(() => {})
247
+ }
248
+
249
+ dest = toFile
250
+ if (captureBody) {
251
+ const read = await ctx.exec(`cat ${quote(toFile)}`)
252
+ bodyText = read.stdout
253
+ }
254
+
255
+ // Return early (changed is always true on successful write)
256
+ return {
257
+ url: parsedUrl,
258
+ changed: true,
259
+ status,
260
+ headers: parsedHeaders,
261
+ body: bodyText,
262
+ dest
263
+ }
264
+ }
265
+
266
+ if (captureBody) {
267
+ bodyText = res.stdout
268
+ }
269
+
270
+ // Best-effort status parse without -D
271
+ if (typeof status === "undefined" && !save_headers_to) {
272
+ const m = (res.stderr || "").match(/HTTP\/\d+\.\d+\s+(\d{3})/)
273
+ if (m) status = Number(m[1])
274
+ }
275
+
276
+ return {
277
+ url: parsedUrl,
278
+ changed: false,
279
+ status,
280
+ headers: parsedHeaders,
281
+ body: bodyText,
282
+ dest
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Build a URL query string from key-value pairs.
289
+ * Skips null/undefined. Booleans and numbers are stringified.
290
+ * @param q Query params map
291
+ * @returns A string beginning with "?" or an empty string
292
+ * @internal
293
+ */
294
+ function buildQueryString(q?: HttpModuleOptions["query"]): string {
295
+ if (!q) return ""
296
+ const parts: string[] = []
297
+ for (const [k, v] of Object.entries(q)) {
298
+ if (v === null || typeof v === "undefined") continue
299
+ parts.push(
300
+ `${encodeURIComponent(k)}=${encodeURIComponent(
301
+ typeof v === "string" ? v : String(v)
302
+ )}`
303
+ )
304
+ }
305
+ return parts.length ? `?${parts.join("&")}` : ""
306
+ }
307
+
308
+ /**
309
+ * Normalize and prepare body/headers for JSON requests.
310
+ * - When body is an object, ensures `Content-Type: application/json` and encodes as JSON.
311
+ * - When body is a string/Uint8Array, uses `--data-binary` with the literal content.
312
+ * @param body Body value
313
+ * @param headers Request headers (used to detect pre-set content-type)
314
+ * @returns http data argument and extra header arguments
315
+ * @internal
316
+ */
317
+ function ensureJsonBody(
318
+ body: HttpModuleOptions["body"],
319
+ headers: Record<string, string>
320
+ ): { dataArg?: string; headerArgs: string[] } {
321
+ if (body == null) return { headerArgs: [] }
322
+ const hasCT = Object.keys(headers).some(
323
+ (k) => k.toLowerCase() === "content-type"
324
+ )
325
+ const headerArgs: string[] = []
326
+
327
+ if (typeof body === "string") {
328
+ return { dataArg: `--data-binary ${JSON.stringify(body)}`, headerArgs }
329
+ }
330
+
331
+ if (body instanceof Uint8Array) {
332
+ const text = new TextDecoder().decode(body)
333
+ return { dataArg: `--data-binary ${JSON.stringify(text)}`, headerArgs }
334
+ }
335
+
336
+ if (!hasCT)
337
+ headerArgs.push(`-H ${JSON.stringify("Content-Type: application/json")}`)
338
+ return {
339
+ dataArg: `--data-binary ${JSON.stringify(JSON.stringify(body))}`,
340
+ headerArgs
341
+ }
342
+ }
@@ -0,0 +1,253 @@
1
+ import { type ModuleConstraints } from "../../interfaces/module.interface"
2
+ import { type Katmer } from "../../interfaces/task.interface"
3
+ import type {
4
+ HttpModuleOptions,
5
+ HttpModuleResult,
6
+ HttpOutput
7
+ } from "./http.module"
8
+ import { toOctal } from "../../utils/number.utils"
9
+ import { mkdir, writeFile, readFile, chmod, chown } from "fs/promises"
10
+ import { basename, dirname } from "path"
11
+ import type { BodyInit } from "bun"
12
+ import type { KatmerProvider } from "../../interfaces/provider.interface"
13
+ import { KatmerModule } from "../../module"
14
+
15
+ export class HttpModule extends KatmerModule<
16
+ HttpModuleOptions,
17
+ HttpModuleResult
18
+ > {
19
+ static name = "http" as const
20
+
21
+ constraints = {
22
+ platform: { any: true }
23
+ } satisfies ModuleConstraints
24
+
25
+ async check(): Promise<void> {
26
+ const { url } = this.params || ({} as HttpModuleOptions)
27
+ if (!url || typeof url !== "string" || !url.trim()) {
28
+ throw new Error("'url' is required")
29
+ }
30
+ if (!URL.canParse(url)) {
31
+ throw new Error("'url' is not a valid URL")
32
+ }
33
+ }
34
+
35
+ async execute(_ctx: Katmer.TaskContext): Promise<HttpModuleResult> {
36
+ const {
37
+ url,
38
+ method = "GET",
39
+ headers = {},
40
+ query,
41
+ body,
42
+ bodyFile,
43
+ auth,
44
+ timeout = 30,
45
+ follow_redirects = true,
46
+ validate_certs = true,
47
+ output,
48
+ save_headers_to,
49
+ fail_on_http_error = true,
50
+ retry,
51
+ mode,
52
+ owner,
53
+ group
54
+ } = this.params
55
+
56
+ const parsedUrl = new URL(url)
57
+ for (const [key, val] of Object.entries(query || {})) {
58
+ parsedUrl.searchParams.set(key, String(val))
59
+ }
60
+ const finalUrl = parsedUrl.toString()
61
+ const reqHeaders = new Headers(headers)
62
+
63
+ // Auth
64
+ if (auth?.type === "basic") {
65
+ const token = Buffer.from(`${auth.username}:${auth.password}`).toString(
66
+ "base64"
67
+ )
68
+ reqHeaders.set("Authorization", `Basic ${token}`)
69
+ } else if (auth?.type === "bearer") {
70
+ reqHeaders.set("Authorization", `Bearer ${auth.token}`)
71
+ }
72
+
73
+ // Prepare body
74
+ let reqBody: BodyInit | undefined
75
+ if (bodyFile) {
76
+ reqBody = await readFile(bodyFile)
77
+ } else if (typeof body !== "undefined") {
78
+ if (
79
+ typeof body === "object" &&
80
+ !(body instanceof Uint8Array) &&
81
+ !reqHeaders.has("Content-Type")
82
+ ) {
83
+ reqHeaders.set("Content-Type", "application/json")
84
+ reqBody = JSON.stringify(body)
85
+ } else if (typeof body === "string" || body instanceof Uint8Array) {
86
+ reqBody = body
87
+ }
88
+ }
89
+
90
+ // Setup timeout and retry
91
+ const controller = new AbortController()
92
+ const timer = setTimeout(() => controller.abort(), timeout * 1000)
93
+
94
+ let res: Response | undefined
95
+ let lastError: any
96
+ const tries = retry?.tries ?? 1
97
+ const delay = retry?.delay ?? 0
98
+
99
+ for (let i = 0; i < tries; i++) {
100
+ try {
101
+ res = await fetch(finalUrl, {
102
+ method,
103
+ headers: reqHeaders,
104
+ body: reqBody,
105
+ redirect: follow_redirects ? "follow" : "manual",
106
+ signal: controller.signal
107
+ })
108
+ break
109
+ } catch (err) {
110
+ lastError = err
111
+ if (i < tries - 1) {
112
+ await new Promise((r) => setTimeout(r, delay * 1000))
113
+ }
114
+ }
115
+ }
116
+
117
+ clearTimeout(timer)
118
+
119
+ if (!res) {
120
+ throw {
121
+ url: parsedUrl,
122
+ changed: false,
123
+ msg: `HTTP request failed: ${String(lastError)}`,
124
+ status: undefined,
125
+ headers: undefined,
126
+ body: undefined,
127
+ dest: undefined
128
+ } satisfies HttpModuleResult
129
+ }
130
+
131
+ const status = res.status
132
+ const headersText = Array.from(res.headers.entries())
133
+ .map(([k, v]) => `${k}: ${v}`)
134
+ .join("\n")
135
+
136
+ const parsedHeaders = res.headers.toJSON()
137
+
138
+ // Save headers if requested
139
+ if (save_headers_to) {
140
+ await mkdir(dirname(save_headers_to), { recursive: true })
141
+ const hdrTxt = `HTTP/${res.ok ? "1.1" : ""} ${status}\n${headersText}\n`
142
+ await writeFile(save_headers_to, hdrTxt, "utf8")
143
+ }
144
+
145
+ // Handle output
146
+ const normalizedOutput: HttpOutput =
147
+ typeof output === "string" ? { toFile: output } : output || {}
148
+ const toFile = normalizedOutput.toFile
149
+ const captureBody =
150
+ normalizedOutput.captureBody !== undefined ?
151
+ !!normalizedOutput.captureBody
152
+ : false
153
+
154
+ let bodyText: string | undefined
155
+ let dest: string | undefined
156
+
157
+ if (toFile) {
158
+ // If we also want to capture the body, clone the response
159
+ if (captureBody) {
160
+ const resCopy = res.clone()
161
+ const [ab, text] = await Promise.all([
162
+ res.arrayBuffer(), // for writing the file
163
+ resCopy.text() // for returning body as text
164
+ ])
165
+ const buf = new Uint8Array(ab)
166
+
167
+ await mkdir(dirname(toFile), { recursive: true })
168
+ await writeFile(toFile, buf)
169
+
170
+ if (mode != null) await chmod(toFile, toOctal(mode) ?? 0o644)
171
+ if (owner != null || group != null) {
172
+ await chown(
173
+ toFile,
174
+ Number(owner ?? process.getuid?.() ?? 0),
175
+ Number(group ?? process.getgid?.() ?? 0)
176
+ ).catch(() => {})
177
+ }
178
+
179
+ dest = toFile
180
+ bodyText = text
181
+
182
+ return {
183
+ url: parsedUrl,
184
+ changed: true,
185
+ status,
186
+ headers: Object.fromEntries(res.headers.entries()),
187
+ body: bodyText,
188
+ dest
189
+ } satisfies HttpModuleResult
190
+ } else {
191
+ // No capture → just consume once
192
+ const ab = await res.arrayBuffer()
193
+ const buf = new Uint8Array(ab)
194
+
195
+ await mkdir(dirname(toFile), { recursive: true })
196
+ await writeFile(toFile, buf)
197
+
198
+ if (mode != null) await chmod(toFile, toOctal(mode) ?? 0o644)
199
+ if (owner != null || group != null) {
200
+ await chown(
201
+ toFile,
202
+ Number(owner ?? process.getuid?.() ?? 0),
203
+ Number(group ?? process.getgid?.() ?? 0)
204
+ ).catch(() => {})
205
+ }
206
+
207
+ dest = toFile
208
+ return {
209
+ url: parsedUrl,
210
+ changed: true,
211
+ status,
212
+ headers: Object.fromEntries(res.headers.entries()),
213
+ body: undefined,
214
+ dest
215
+ } satisfies HttpModuleResult
216
+ }
217
+ }
218
+
219
+ // No file output → safe to read once as text
220
+ if (captureBody) {
221
+ bodyText = await res.text()
222
+ }
223
+
224
+ if (!res.ok && fail_on_http_error) {
225
+ throw {
226
+ url: parsedUrl,
227
+ changed: false,
228
+ status,
229
+ headers: Object.fromEntries(res.headers.entries()),
230
+ body: bodyText,
231
+ dest,
232
+ msg: `HTTP ${status}: ${res.statusText}`
233
+ } satisfies HttpModuleResult
234
+ }
235
+
236
+ return {
237
+ url: parsedUrl,
238
+ changed: false,
239
+ status,
240
+ headers: Object.fromEntries(res.headers.entries()),
241
+ body: bodyText,
242
+ dest
243
+ } satisfies HttpModuleResult
244
+ }
245
+
246
+ cleanup(ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {
247
+ return Promise.resolve(undefined)
248
+ }
249
+
250
+ initialize(ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {
251
+ return Promise.resolve(undefined)
252
+ }
253
+ }