@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.
- package/README.md +1 -0
- package/cli/katmer.js +28 -0
- package/cli/run.ts +16 -0
- package/index.ts +5 -0
- package/lib/config.ts +82 -0
- package/lib/interfaces/config.interface.ts +113 -0
- package/lib/interfaces/executor.interface.ts +13 -0
- package/lib/interfaces/module.interface.ts +170 -0
- package/lib/interfaces/provider.interface.ts +214 -0
- package/lib/interfaces/task.interface.ts +100 -0
- package/lib/katmer.ts +126 -0
- package/lib/lookup/env.lookup.ts +13 -0
- package/lib/lookup/file.lookup.ts +23 -0
- package/lib/lookup/index.ts +46 -0
- package/lib/lookup/url.lookup.ts +21 -0
- package/lib/lookup/var.lookup.ts +13 -0
- package/lib/module.ts +560 -0
- package/lib/module_registry.ts +64 -0
- package/lib/modules/apt-repository/apt-repository.module.ts +435 -0
- package/lib/modules/apt-repository/apt-sources-list.ts +363 -0
- package/lib/modules/apt.module.ts +546 -0
- package/lib/modules/archive.module.ts +280 -0
- package/lib/modules/become.module.ts +119 -0
- package/lib/modules/copy.module.ts +807 -0
- package/lib/modules/cron.module.ts +541 -0
- package/lib/modules/debug.module.ts +231 -0
- package/lib/modules/gather_facts.module.ts +605 -0
- package/lib/modules/git.module.ts +243 -0
- package/lib/modules/hostname.module.ts +213 -0
- package/lib/modules/http/http.curl.module.ts +342 -0
- package/lib/modules/http/http.local.module.ts +253 -0
- package/lib/modules/http/http.module.ts +298 -0
- package/lib/modules/index.ts +14 -0
- package/lib/modules/package.module.ts +283 -0
- package/lib/modules/script.module.ts +121 -0
- package/lib/modules/set_fact.module.ts +171 -0
- package/lib/modules/systemd_service.module.ts +373 -0
- package/lib/modules/template.module.ts +478 -0
- package/lib/providers/local.provider.ts +336 -0
- package/lib/providers/provider_response.ts +20 -0
- package/lib/providers/ssh/ssh.provider.ts +420 -0
- package/lib/providers/ssh/ssh.utils.ts +31 -0
- package/lib/schemas/katmer_config.schema.json +358 -0
- package/lib/target_resolver.ts +298 -0
- package/lib/task/controls/environment.control.ts +42 -0
- package/lib/task/controls/index.ts +13 -0
- package/lib/task/controls/loop.control.ts +89 -0
- package/lib/task/controls/register.control.ts +23 -0
- package/lib/task/controls/until.control.ts +64 -0
- package/lib/task/controls/when.control.ts +25 -0
- package/lib/task/task.ts +225 -0
- package/lib/utils/ajv.utils.ts +24 -0
- package/lib/utils/cls.ts +4 -0
- package/lib/utils/datetime.utils.ts +15 -0
- package/lib/utils/errors.ts +25 -0
- package/lib/utils/execute-shell.ts +116 -0
- package/lib/utils/file.utils.ts +68 -0
- package/lib/utils/http.utils.ts +10 -0
- package/lib/utils/json.utils.ts +15 -0
- package/lib/utils/number.utils.ts +9 -0
- package/lib/utils/object.utils.ts +11 -0
- package/lib/utils/os.utils.ts +31 -0
- package/lib/utils/path.utils.ts +9 -0
- package/lib/utils/renderer/render_functions.ts +3 -0
- package/lib/utils/renderer/renderer.ts +89 -0
- package/lib/utils/renderer/twig.ts +191 -0
- package/lib/utils/string.utils.ts +33 -0
- package/lib/utils/typed-event-emitter.ts +26 -0
- package/lib/utils/unix.utils.ts +91 -0
- package/lib/utils/windows.utils.ts +92 -0
- 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
|
+
}
|