@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,13 @@
1
+ import { LoopControl } from "./loop.control"
2
+ import { RegisterControl } from "./register.control"
3
+ import { WhenControl } from "./when.control"
4
+ import { UntilControl } from "./until.control"
5
+ import { sortBy } from "es-toolkit"
6
+ import { EnvironmentControl } from "./environment.control"
7
+
8
+ export const TaskControls = sortBy(
9
+ [LoopControl, RegisterControl, WhenControl, UntilControl, EnvironmentControl],
10
+ ["order"]
11
+ )
12
+
13
+ export const TaskControlKeys = TaskControls.map((ctrl) => ctrl.configKey)
@@ -0,0 +1,89 @@
1
+ import type { Katmer } from "../../interfaces/task.interface"
2
+ import { defaults, isObjectLike } from "es-toolkit/compat"
3
+ import { wrapInArray } from "../../utils/json.utils"
4
+ import type { KatmerTask } from "../task"
5
+ import { evalExpr } from "../../utils/renderer/renderer"
6
+ import type { ModuleCommonReturn } from "../../interfaces/module.interface"
7
+
8
+ const configKey = "loop" as const
9
+ export const LoopControl = {
10
+ order: 100,
11
+ configKey,
12
+ register(task: KatmerTask, cfg?: Katmer.TaskRule[typeof configKey]) {
13
+ if (cfg) {
14
+ const control = defaults(
15
+ cfg && typeof cfg === "object" && "for" in cfg ?
16
+ cfg
17
+ : {
18
+ for: (cfg || []) as string[] | string
19
+ },
20
+ {
21
+ for: [] as string[],
22
+ index_var: "index",
23
+ break_when: [] as string[],
24
+ loop_var: "item",
25
+ extended: false,
26
+ extended_allitems: true
27
+ } satisfies Katmer.LoopControl
28
+ )
29
+ control.break_when = wrapInArray(control.break_when)
30
+
31
+ const baseExecute = task.execute
32
+ task.execute = async function runWithLoop(ctx: Katmer.TaskContext) {
33
+ ctx.logger.trace(`[loop] start`)
34
+ const loops =
35
+ typeof control.for === "string" ? await evalExpr(control.for)
36
+ : isObjectLike(control.for) ? control.for
37
+ : []
38
+
39
+ const loopEntries = Object.entries(loops)
40
+ const loopItems = Object.values(loops)
41
+ const loopResults = {
42
+ changed: false,
43
+ skipped: undefined as boolean | undefined,
44
+ failed: false,
45
+ results: [] as (ModuleCommonReturn | { skipped: true })[]
46
+ }
47
+ taskLoop: for (let i = 0; i < loopEntries.length; i++) {
48
+ const [loop_key, loop_val] = loopEntries[i]
49
+ ctx.variables[control.index_var] = loop_key
50
+ ctx.variables[control.loop_var] = loop_val
51
+
52
+ if (control.extended) {
53
+ ctx.variables.katmer_loop = {
54
+ allitems: control.extended_allitems ? loopItems : undefined,
55
+ index: i + 1,
56
+ index0: i,
57
+ revindex: loopItems.length - i,
58
+ revindex0: loopItems.length - i - 1,
59
+ first: i === 0,
60
+ last: i === loopItems.length - 1,
61
+ length: loopItems.length,
62
+ previtem: loopItems[i - 1],
63
+ nextitem: loopItems[i + 1]
64
+ }
65
+ }
66
+ ctx.logger.trace(`[loop] ${i} ${loop_key} ${loop_val}`)
67
+ const result = await baseExecute.call(task, ctx)
68
+
69
+ if (result) {
70
+ result.item = loop_val
71
+ loopResults.changed = loopResults.changed || result.changed
72
+ loopResults.failed = loopResults.failed || !!result.failed
73
+ loopResults.skipped = !!(result.skipped && loopResults.skipped)
74
+ loopResults.results.push(result)
75
+ }
76
+
77
+ for (const condition of control.break_when) {
78
+ if (await evalExpr(condition, ctx.variables)) {
79
+ break taskLoop
80
+ }
81
+ }
82
+ }
83
+ ctx.logger.trace(`[loop] end`)
84
+
85
+ return loopResults
86
+ }
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,23 @@
1
+ import type { KatmerTask } from "../task"
2
+ import type { Katmer } from "../../interfaces/task.interface"
3
+
4
+ const configKey = "register" as const
5
+
6
+ export const RegisterControl = {
7
+ order: 1000,
8
+ configKey,
9
+ register(task: KatmerTask, cfg?: Katmer.TaskRule[typeof configKey]) {
10
+ if (cfg) {
11
+ const baseExecute = task.execute
12
+ task.execute = async function registerResult(ctx: Katmer.TaskContext) {
13
+ ctx.logger.trace(`[register] start`)
14
+
15
+ const result = await baseExecute.call(task, ctx)
16
+ ctx.variables[cfg] = result
17
+
18
+ ctx.logger.trace(`[register] end`)
19
+ return result
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ import type { KatmerTask } from "../task"
2
+ import type { Katmer } from "../../interfaces/task.interface"
3
+ import { evalExpr } from "../../utils/renderer/renderer"
4
+ import { delay } from "es-toolkit"
5
+ import type { ModuleCommonReturn } from "../../interfaces/module.interface"
6
+
7
+ const configKey = "until" as const
8
+
9
+ export const UntilControl = {
10
+ order: 50,
11
+ configKey,
12
+ register(task: KatmerTask, cfg?: Katmer.TaskRule[typeof configKey]) {
13
+ if (cfg) {
14
+ const control =
15
+ cfg && typeof cfg === "object" ?
16
+ cfg
17
+ : {
18
+ condition: cfg || ""
19
+ }
20
+ const baseExecute = task.execute
21
+ task.execute = async function runUntil(ctx: Katmer.TaskContext) {
22
+ let shouldRun = false
23
+ let attempts = 0
24
+ let result = {} as ModuleCommonReturn
25
+ do {
26
+ result = Object.assign(
27
+ result || {},
28
+ await baseExecute.call(task, ctx)
29
+ )
30
+
31
+ if (control.condition) {
32
+ const untilResult = await evalExpr(control.condition, ctx.variables)
33
+ if (untilResult) {
34
+ shouldRun = false
35
+ } else {
36
+ if (control.retries && attempts === control.retries + 1) {
37
+ shouldRun = false
38
+ break
39
+ }
40
+
41
+ if (!result) {
42
+ result = {} as any
43
+ }
44
+ result.retries = control.retries
45
+ result.attempts = attempts++
46
+ ctx.log("error", {
47
+ msg: `[FAILED]`,
48
+ result: result
49
+ })
50
+ if (control.delay) {
51
+ await delay(control.delay)
52
+ }
53
+ }
54
+ } else {
55
+ ctx.log("debug", "Condiiton failed")
56
+ shouldRun = false
57
+ }
58
+ } while (shouldRun)
59
+
60
+ return result
61
+ }
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,25 @@
1
+ import type { KatmerTask } from "../task"
2
+ import type { Katmer } from "../../interfaces/task.interface"
3
+ import { evalExpr } from "../../utils/renderer/renderer"
4
+
5
+ const configKey = "when" as const
6
+
7
+ export const WhenControl = {
8
+ order: 10,
9
+ configKey,
10
+ register(task: KatmerTask, cfg?: Katmer.TaskRule[typeof configKey]) {
11
+ if (cfg) {
12
+ const baseExecute = task.execute
13
+ task.execute = async function runWhen(ctx: Katmer.TaskContext) {
14
+ const whenResult = await evalExpr(cfg, ctx.variables)
15
+ if (!whenResult) {
16
+ return {
17
+ changed: false,
18
+ skipped: true
19
+ }
20
+ }
21
+ return await baseExecute.call(task, ctx)
22
+ }
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,225 @@
1
+ import type { Katmer } from "../katmer"
2
+ import { type KatmerCore } from "../katmer"
3
+ import type { KatmerTargetResolver } from "../target_resolver"
4
+ import { wrapInArray } from "../utils/json.utils"
5
+ import type { ModuleCommonReturn } from "../interfaces/module.interface"
6
+ import { omit, toMerged } from "es-toolkit"
7
+ import type { KatmerProvider } from "../interfaces/provider.interface"
8
+ import { TaskControlKeys, TaskControls } from "./controls"
9
+ import { msToDelta, nowIso } from "../utils/datetime.utils"
10
+ import {
11
+ evalExpr,
12
+ evalObjectVals,
13
+ evalTemplate
14
+ } from "../utils/renderer/renderer"
15
+ import { merge } from "es-toolkit/compat"
16
+ import { TypedEventEmitter } from "../utils/typed-event-emitter"
17
+ import { AsyncLocalStorage } from "node:async_hooks"
18
+ import { cls } from "../utils/cls"
19
+ import { ExecutionFailedError, TaskExecutionFailedError } from "../utils/errors"
20
+ import type { KatmerModule } from "../module"
21
+
22
+ export class KatmerTask extends TypedEventEmitter<{
23
+ "before:execute": [KatmerTask, Katmer.TaskContext]
24
+ "module:check": [KatmerTask, Katmer.TaskContext, KatmerModule]
25
+ "module:init": [KatmerTask, Katmer.TaskContext, KatmerModule]
26
+ "module:execute": [
27
+ KatmerTask,
28
+ Katmer.TaskContext,
29
+ KatmerModule,
30
+ ModuleCommonReturn
31
+ ]
32
+ "after:execute": [KatmerTask, Katmer.TaskContext, ModuleCommonReturn]
33
+ }> {
34
+ variables = {} as Record<string, any>
35
+ modules = [] as [string, any][]
36
+ targets: string[]
37
+
38
+ constructor(
39
+ public core: KatmerCore,
40
+ public targetResolver: KatmerTargetResolver,
41
+ public cfg: Katmer.Task
42
+ ) {
43
+ super()
44
+ const taskModules = omit(cfg, [
45
+ "name",
46
+ "become",
47
+ "targets",
48
+ "variables",
49
+ ...TaskControlKeys
50
+ ])
51
+
52
+ this.modules = this.normalizeModules(taskModules)
53
+
54
+ // TODO: use better way for internal modules
55
+ if (cfg.become) {
56
+ this.modules.unshift(["become", cfg.become])
57
+ }
58
+
59
+ this.variables = cfg.variables || {}
60
+ this.targets = wrapInArray(cfg.targets)
61
+
62
+ for (const taskControl of TaskControls) {
63
+ taskControl.register(this, cfg[taskControl.configKey] as any)
64
+ }
65
+ }
66
+
67
+ async run(context: Record<string, any> = {}) {
68
+ for (const target of this.targets) {
69
+ const providers = this.targetResolver.resolveTargets(target)
70
+
71
+ for (const runFor of providers) {
72
+ const provider = await this.targetResolver.resolveProvider(runFor)
73
+ await this.runForProvider(provider, context)
74
+ }
75
+ }
76
+ }
77
+
78
+ async runForProvider(
79
+ provider: KatmerProvider,
80
+ context: Record<string, any> = {}
81
+ ) {
82
+ await provider.ensureReady()
83
+ const ctx = this.generateTaskContext(provider, context)
84
+ return await cls.run(ctx, async () => {
85
+ return await this.execute(ctx)
86
+ })
87
+ }
88
+
89
+ async execute(ctx: Katmer.TaskContext) {
90
+ this.emit("before:execute", this, ctx)
91
+
92
+ let lastResult: any
93
+
94
+ const modules = [] as KatmerModule[]
95
+ for (const [moduleName, moduleParams] of this.modules) {
96
+ modules.push(
97
+ this.core.registry.get(moduleName, moduleParams) as KatmerModule
98
+ )
99
+ }
100
+
101
+ for (const module of modules) {
102
+ await module.doCheck(ctx)
103
+ this.emit("module:check", this, ctx, module)
104
+
105
+ ctx.logger.trace(`Initializing module`)
106
+ await module.doInitialize?.(ctx)
107
+ this.emit("module:init", this, ctx, module)
108
+
109
+ ctx.logger.trace(`Executing module`)
110
+
111
+ // Per-module timing
112
+ const startAt = Date.now()
113
+ const startIso = nowIso()
114
+
115
+ try {
116
+ const res = await module.doExecute(ctx)
117
+ const endAt = Date.now()
118
+ const endIso = nowIso()
119
+
120
+ // Ensure ModuleCommonReturn core fields are present if missing
121
+ if (res.changed === undefined) res.changed = false
122
+ if (res.failed === undefined) res.failed = false
123
+
124
+ if (!res.start) res.start = startIso
125
+ if (!res.end) res.end = endIso
126
+ if (!res.delta) {
127
+ // If module supplied start/end, honor them; otherwise compute
128
+ const started = Date.parse(res.start || startIso) || startAt
129
+ const ended = Date.parse(res.end || endIso) || endAt
130
+ res.delta = msToDelta(ended - started)
131
+ }
132
+
133
+ lastResult = res
134
+ } catch (err: any) {
135
+ const endAt = Date.now()
136
+ const endIso = nowIso()
137
+
138
+ // Convert thrown error into ModuleCommonReturn shape
139
+ lastResult = {
140
+ changed: false,
141
+ failed: true,
142
+ msg:
143
+ typeof err === "string" ? err : (
144
+ err?.message || err?.msg || "Task failed"
145
+ ),
146
+ stdout: err?.stdout,
147
+ stderr: err?.stderr,
148
+ start: startIso,
149
+ end: endIso,
150
+ delta: msToDelta(endAt - startAt)
151
+ }
152
+ // Stop at the first failure (keeps current behavior of bubbling errors)
153
+ break
154
+ } finally {
155
+ this.emit("module:execute", this, ctx, module, lastResult)
156
+ }
157
+ }
158
+
159
+ if (lastResult.failed) {
160
+ throw new TaskExecutionFailedError(this, lastResult)
161
+ } else {
162
+ ctx.logger.info({ msg: "Task finished", result: lastResult })
163
+ }
164
+
165
+ this.emit("after:execute", this, ctx, lastResult)
166
+ return lastResult
167
+ }
168
+
169
+ protected normalizeModules(modules: Record<string, unknown>) {
170
+ const registered = [] as [string, any][]
171
+
172
+ const moduleEntries = Object.entries(modules)
173
+ if (moduleEntries.length === 0) {
174
+ throw new Error("No modules encountered in the task")
175
+ }
176
+ if (moduleEntries.length > 1) {
177
+ throw new Error("Only one module is allowed in the task")
178
+ }
179
+
180
+ const [moduleName, moduleParams] = moduleEntries[0]
181
+ if (this.core.registry.has(moduleName)) {
182
+ registered.push([moduleName, moduleParams])
183
+ } else {
184
+ throw new Error(`Unknown module encountered: ${moduleName}`)
185
+ }
186
+ return registered
187
+ }
188
+
189
+ generateTaskContext<Provider extends KatmerProvider>(
190
+ provider: Provider,
191
+ context: Record<string, any> = {}
192
+ ): Katmer.TaskContext<Provider> {
193
+ const logger = provider.logger.child({ task: this.cfg.name || "" })
194
+
195
+ return {
196
+ config: this.core.config,
197
+ exec: provider.executor(),
198
+ async execSafe(...args: [any, any]) {
199
+ try {
200
+ return (await this.exec(...args)) as any
201
+ } catch (err: any) {
202
+ return err
203
+ }
204
+ },
205
+ fail(msg: string | { message: string }): never {
206
+ throw msg
207
+ },
208
+ log(
209
+ level: "fatal" | "error" | "warn" | "info" | "debug" | "trace",
210
+ message: any
211
+ ): void {
212
+ logger[level](message)
213
+ },
214
+ logger: logger,
215
+ progress(data) {
216
+ console.info("Progress:", data)
217
+ },
218
+ provider,
219
+ variables: merge(context, this.variables || {}),
220
+ warn(opts: { message: string } | string): void {
221
+ console.warn("WARN", opts)
222
+ }
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,24 @@
1
+ import type { ErrorObject } from "ajv"
2
+
3
+ export function normalizeAjvError(error: ErrorObject): string {
4
+ let message: string
5
+ error.message = error.message?.toLowerCase()
6
+ const objPath = error.instancePath?.replace("/", "").replaceAll("/", ".")
7
+
8
+ if (error.keyword === "pattern") {
9
+ message = `"${objPath}" contains invalid characters`
10
+ } else if (error.message?.startsWith("must ") && error.instancePath!.length) {
11
+ message = `"${objPath || ""}" ${error.message}`
12
+ } else {
13
+ message = error.message!
14
+ }
15
+
16
+ if (
17
+ error.keyword === "additionalProperties" &&
18
+ error.params?.additionalProperty
19
+ ) {
20
+ message = `${message}: "${error.params.additionalProperty}"`
21
+ }
22
+
23
+ return message
24
+ }
@@ -0,0 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import type { Katmer } from "../interfaces/task.interface"
3
+
4
+ export const cls = new AsyncLocalStorage<Katmer.TaskContext>()
@@ -0,0 +1,15 @@
1
+ export function nowIso(): string {
2
+ return new Date().toISOString()
3
+ }
4
+ export function msToDelta(ms: number): string {
5
+ // format: H:MM:SS.mmm (e.g., "0:00:00.123")
6
+ const sign = ms < 0 ? "-" : ""
7
+ ms = Math.abs(ms)
8
+ const h = Math.floor(ms / 3600000)
9
+ const m = Math.floor((ms % 3600000) / 60000)
10
+ const s = Math.floor((ms % 60000) / 1000)
11
+ const msRest = ms % 1000
12
+ const pad2 = (n: number) => n.toString().padStart(2, "0")
13
+ const pad3 = (n: number) => n.toString().padStart(3, "0")
14
+ return `${sign}${h}:${pad2(m)}:${pad2(s)}.${pad3(msRest)}`
15
+ }
@@ -0,0 +1,25 @@
1
+ import type { ProviderResponse } from "../providers/provider_response"
2
+ import type { Katmer } from "../katmer"
3
+ import type { KatmerTask } from "../task/task"
4
+
5
+ export class KatmerError extends Error {}
6
+ export class ExecutionFailedError extends KatmerError {
7
+ constructor(
8
+ public result: ProviderResponse,
9
+ public message = "Execution failed"
10
+ ) {
11
+ super(message)
12
+ }
13
+ }
14
+
15
+ export class TaskExecutionFailedError extends ExecutionFailedError {
16
+ task: string | undefined
17
+ constructor(
18
+ task: KatmerTask,
19
+ public result: ProviderResponse,
20
+ public message = "Task execution failed"
21
+ ) {
22
+ super(result, message)
23
+ this.task = task.cfg.name
24
+ }
25
+ }
@@ -0,0 +1,116 @@
1
+ import { uint8ArrayToString } from "uint8array-extras"
2
+ import { safeJsonParse } from "./json.utils"
3
+
4
+ export async function executeScript<T extends boolean = false>(
5
+ scriptContents: string,
6
+ opts: {
7
+ /**
8
+ * Parse stdout/stderr as json. If not valid json the data will be available in `.message`
9
+ */
10
+ json?: T
11
+ /**
12
+ * If provided will be called with each line of script stdout
13
+ */
14
+ stream?: (
15
+ err?: (T extends true ? any : string) | null,
16
+ data?: (T extends true ? any : string) | null
17
+ ) => void
18
+ } = {}
19
+ ) {
20
+ const command = Bun.spawn(["bash", "-s", "<", "<(cat)"], {
21
+ stdin: new Response(scriptContents),
22
+ stdout: "pipe",
23
+ stderr: "pipe"
24
+ })
25
+
26
+ if (opts.stream) {
27
+ command.stdout.pipeTo(
28
+ new WritableStream({
29
+ write(log) {
30
+ if (log.length) {
31
+ const str = uint8ArrayToString(log).trim()
32
+ if (str) {
33
+ opts.stream?.(undefined, opts.json ? safeJsonParse(str) : str)
34
+ }
35
+ }
36
+ }
37
+ })
38
+ )
39
+
40
+ command.stderr.pipeTo(
41
+ new WritableStream({
42
+ write(log) {
43
+ const str = uint8ArrayToString(log).trim()
44
+ if (str) {
45
+ opts.stream?.(opts.json ? safeJsonParse(str) : str)
46
+ }
47
+ }
48
+ })
49
+ )
50
+ }
51
+
52
+ const failed = (await command.exited) !== 0
53
+
54
+ if (!opts.stream) {
55
+ let stdout = await new Response(command.stdout).text()
56
+ let stderr = await new Response(command.stderr).text()
57
+ if (opts.json) {
58
+ stdout = safeJsonParse(stdout)
59
+ stderr = safeJsonParse(stderr)
60
+ }
61
+
62
+ if (failed) {
63
+ throw stderr
64
+ }
65
+ return stdout
66
+ }
67
+ }
68
+
69
+ export async function executeShellCommand(cmd: string) {
70
+ try {
71
+ const command = Bun.spawn(cmd.split(" "), {
72
+ stdout: "pipe",
73
+ stderr: "pipe"
74
+ })
75
+
76
+ const output = {
77
+ code: null as null | number,
78
+ stdout: [] as string[],
79
+ stderr: [] as string[]
80
+ }
81
+ command.stdout.pipeTo(
82
+ new WritableStream({
83
+ write(log) {
84
+ if (log.length) {
85
+ const str = uint8ArrayToString(log).trim()
86
+ if (str) {
87
+ output.stdout.push(str)
88
+ }
89
+ }
90
+ }
91
+ })
92
+ )
93
+
94
+ command.stderr.pipeTo(
95
+ new WritableStream({
96
+ write(log) {
97
+ const str = uint8ArrayToString(log)
98
+ output.stderr.push(str)
99
+ }
100
+ })
101
+ )
102
+
103
+ output.code = await command.exited
104
+ return output
105
+ } catch (e) {
106
+ const err = e as InstanceType<typeof Bun.$.ShellError>
107
+ if ("exitCode" in err) {
108
+ return {
109
+ code: err.exitCode,
110
+ stdout: [err.stdout.toString("utf-8")],
111
+ stderr: [err.stderr.toString("utf-8")]
112
+ }
113
+ }
114
+ throw e
115
+ }
116
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path"
2
+ import fs, { exists, mkdir } from "node:fs/promises"
3
+ import { evalTemplate } from "./renderer/renderer"
4
+ import JSON5 from "json5"
5
+
6
+ export function resolveFile(...args: string[]) {
7
+ return path.resolve(process.cwd(), ...args)
8
+ }
9
+
10
+ export async function ensureDirectory(filePath: string): Promise<void> {
11
+ const dir = path.dirname(filePath)
12
+ if (!(await exists(dir))) {
13
+ await mkdir(dir, { recursive: true })
14
+ }
15
+ }
16
+
17
+ export async function readKatmerFile(
18
+ filePath: string,
19
+ opts: {
20
+ cwd?: string
21
+ process?: boolean
22
+ processOpts?: any
23
+ errorMessage?: string
24
+ } = {}
25
+ ): Promise<Record<string, any>> {
26
+ try {
27
+ const isTwigTemplate = filePath.endsWith(".twig")
28
+
29
+ const baseFile = path.basename(filePath.replace(/\.twig$/, ""))
30
+
31
+ let contents = await fs.readFile(
32
+ path.resolve(opts.cwd || process.cwd(), filePath),
33
+ "utf-8"
34
+ )
35
+ if ((isTwigTemplate && opts.process !== false) || opts.process) {
36
+ contents = await evalTemplate(contents, {}, opts.processOpts)
37
+ }
38
+
39
+ return await parseKatmerFile(baseFile, contents)
40
+ } catch (e: any) {
41
+ const message = (typeof e === "string" ? e : e.message) || e
42
+ if (opts.errorMessage) {
43
+ throw new Error(`${opts.errorMessage}: ${message}`)
44
+ }
45
+ throw new Error(message)
46
+ }
47
+ }
48
+
49
+ export async function parseKatmerFile(filename: string, contents?: string) {
50
+ if (!contents) {
51
+ throw new Error(`No contents provided to parse file: ${filename}`)
52
+ }
53
+ try {
54
+ const extension = path.extname(filename)
55
+ if (/\.ya?ml/.test(extension)) {
56
+ return await Bun.YAML.parse(contents)
57
+ } else if (/\.json(c|5|rc)?/.test(extension)) {
58
+ return JSON5.parse(contents)
59
+ } else if (/\.toml/.test(extension)) {
60
+ return Bun.TOML.parse(contents)
61
+ } else {
62
+ throw `Unsupported file type: ${extension}`
63
+ }
64
+ } catch (e: any) {
65
+ const message = (typeof e === "string" ? e : e.message) || e
66
+ throw new Error(message)
67
+ }
68
+ }