@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,363 @@
1
+ import { get } from "es-toolkit/compat"
2
+ import { parseLines } from "../../utils/string.utils"
3
+ import path from "node:path"
4
+ import type { Katmer } from "../../interfaces/task.interface"
5
+ import type { SSHProvider } from "../../providers/ssh/ssh.provider"
6
+ import { UnixComms } from "../../utils/unix.utils"
7
+
8
+ type SourceEntry = {
9
+ valid: boolean
10
+ enabled: boolean
11
+ source: string
12
+ comment: string
13
+ }
14
+
15
+ const VALID_SOURCE_TYPES = new Set(["deb", "deb-src"])
16
+
17
+ export class InvalidSource extends Error {
18
+ constructor(line: string) {
19
+ super(`Invalid or disabled APT source: ${line}`)
20
+ this.name = "InvalidSource"
21
+ }
22
+ }
23
+
24
+ export class SourcesList {
25
+ files: Record<string, (SourceEntry & { n: number })[]> = {}
26
+ files_map: Record<string, string> = {}
27
+ new_repos = new Set<string>()
28
+ default_file!: string
29
+ sources_dir!: string
30
+
31
+ constructor(
32
+ private aptConfig: Record<string, any>,
33
+ private ctx: Katmer.TaskContext<SSHProvider>
34
+ ) {}
35
+
36
+ async init() {
37
+ const rootDir = this.aptConfig["Dir"]
38
+ const aptDir = this.aptConfig["Dir::Etc"]
39
+ const sourcesDir = this.aptConfig["Dir::Etc::sourceparts"]
40
+ const sourcesList = this.aptConfig["Dir::Etc::sourcelist"]
41
+
42
+ this.default_file = path.posix.join(rootDir, aptDir, sourcesList)
43
+ if (await UnixComms.pathIsFile(this.ctx, this.default_file)) {
44
+ await this.load(this.default_file)
45
+ }
46
+
47
+ this.sources_dir = path.posix.join(rootDir, aptDir, sourcesDir)
48
+ const { stdout } = await this.ctx.exec(
49
+ `bash -lc 'shopt -s nullglob; for f in "${this.sources_dir}"/*.list; do echo "$f"; done'`
50
+ )
51
+ const files = stdout
52
+ .split("\n")
53
+ .map((s) => s.trim())
54
+ .filter(Boolean)
55
+
56
+ for (const file of files) {
57
+ if (await UnixComms.pathIsSymlink(this.ctx, file)) {
58
+ const link = await UnixComms.readlink(this.ctx, file)
59
+ if (link) this.files_map[file] = link
60
+ }
61
+ await this.load(file)
62
+ }
63
+ }
64
+
65
+ async load(sourcesFile: string): Promise<void> {
66
+ const group: (SourceEntry & { n: number })[] = []
67
+ const fileContents = await UnixComms.readFileUtf8(this.ctx, sourcesFile)
68
+ if (fileContents) {
69
+ const lines = parseLines(fileContents)
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const res = this._parse_source_line(lines[i])
72
+ group.push({ n: i, ...res })
73
+ }
74
+ this.files[sourcesFile] = group
75
+ } else {
76
+ this.files[sourcesFile] = []
77
+ }
78
+ }
79
+
80
+ async save(): Promise<void> {
81
+ // Build desired contents
82
+ const desired: Record<string, string> = {}
83
+ for (const [filename, sources] of Object.entries(this.files)) {
84
+ if (!sources || sources.length === 0) continue
85
+ const lines: string[] = []
86
+ for (const { enabled, source, comment } of sources) {
87
+ const chunks: string[] = []
88
+ if (!enabled) chunks.push("# ")
89
+ chunks.push(source)
90
+ if (comment) {
91
+ chunks.push(" # ")
92
+ chunks.push(comment)
93
+ }
94
+ chunks.push("\n")
95
+ lines.push(chunks.join(""))
96
+ }
97
+ desired[filename] = lines.join("")
98
+ }
99
+
100
+ // Read current contents for comparison
101
+ const current: Record<string, string> = {}
102
+ const filenames = new Set<string>([
103
+ ...Object.keys(this.files),
104
+ ...Object.keys(this.files_map)
105
+ ])
106
+ for (const filename of filenames) {
107
+ const target = this.files_map[filename] ?? filename
108
+ current[filename] = (await UnixComms.readFileUtf8(this.ctx, target)) ?? ""
109
+ }
110
+
111
+ // Write only changed files
112
+ for (const [filename, sources] of Object.entries(this.files)) {
113
+ const target = this.files_map[filename] ?? filename
114
+ const want = desired[filename] ?? ""
115
+ const have = current[filename] ?? ""
116
+
117
+ if (sources && sources.length > 0) {
118
+ if (want === have) continue
119
+
120
+ const dir = filename.substring(0, filename.lastIndexOf("/")) || "/"
121
+ await UnixComms.mkdirp(this.ctx, dir)
122
+
123
+ const modeRaw = get(this.aptConfig, "mode") as
124
+ | number
125
+ | string
126
+ | undefined
127
+ const numeric =
128
+ modeRaw !== undefined ?
129
+ typeof modeRaw === "number" ?
130
+ modeRaw
131
+ : parseInt(String(modeRaw), 8)
132
+ : undefined
133
+
134
+ await UnixComms.writeFileAtomic(
135
+ this.ctx,
136
+ target,
137
+ want,
138
+ Number.isNaN(numeric as any) ? undefined : numeric
139
+ )
140
+ }
141
+ }
142
+
143
+ // Remove only files that became empty (and only if they existed or are mapped)
144
+ for (const [filename, sources] of Object.entries(this.files)) {
145
+ if (sources && sources.length > 0) continue
146
+ delete this.files[filename]
147
+ await UnixComms.removePath(this.ctx, filename)
148
+ }
149
+ }
150
+
151
+ modify(
152
+ file: string,
153
+ n: number,
154
+ enabled?: boolean,
155
+ source?: string,
156
+ comment?: string
157
+ ): void {
158
+ const current = this.files[file]?.[n]
159
+ if (!current) return
160
+ const valid = current.valid
161
+ const enabledOld = current.enabled
162
+ const sourceOld = current.source
163
+ const commentOld = current.comment
164
+ this.files[file][n] = {
165
+ n,
166
+ valid,
167
+ enabled: this._choice(enabled, enabledOld),
168
+ source: this._choice(source, sourceOld),
169
+ comment: this._choice(comment, commentOld)
170
+ }
171
+ }
172
+
173
+ add_source(line: string, comment = "", file?: string | null) {
174
+ const { source } = this._parse_source_line(line, true)
175
+ const suggested = this._suggest_filename(source)
176
+ this._add_valid_source(source, comment, file || suggested)
177
+ }
178
+
179
+ remove_source(line?: string, regexp?: string) {
180
+ if (regexp) {
181
+ this._remove_valid_source(undefined, regexp)
182
+ } else if (line) {
183
+ const { source } = this._parse_source_line(line, true)
184
+ this._remove_valid_source(source)
185
+ }
186
+ }
187
+
188
+ dump(): Record<string, string> {
189
+ const out: Record<string, string> = {}
190
+ for (const [filename, sources] of Object.entries(this.files)) {
191
+ if (!sources || sources.length === 0) continue
192
+ const lines: string[] = []
193
+ for (const { enabled, source, comment } of sources) {
194
+ const chunks: string[] = []
195
+ if (!enabled) chunks.push("# ")
196
+ chunks.push(source)
197
+ if (comment) {
198
+ chunks.push(" # ")
199
+ chunks.push(comment)
200
+ }
201
+ chunks.push("\n")
202
+ lines.push(chunks.join(""))
203
+ }
204
+ out[filename] = lines.join("")
205
+ }
206
+ return out
207
+ }
208
+
209
+ protected _parse_source_line(
210
+ lineIn: string,
211
+ raiseIfInvalidOrDisabled = false
212
+ ): SourceEntry {
213
+ let valid = false
214
+ let enabled = true
215
+ let source = ""
216
+ let comment = ""
217
+
218
+ let line = lineIn.trim()
219
+ if (line.startsWith("#")) {
220
+ enabled = false
221
+ line = line.slice(1)
222
+ }
223
+
224
+ const hashIdx = line.indexOf("#")
225
+ if (hashIdx > 0) {
226
+ comment = line.slice(hashIdx + 1).trim()
227
+ line = line.slice(0, hashIdx)
228
+ }
229
+
230
+ source = line.trim()
231
+ if (source) {
232
+ const chunks = source.split(/\s+/).filter(Boolean)
233
+ if (chunks.length > 0 && VALID_SOURCE_TYPES.has(chunks[0])) {
234
+ valid = true
235
+ source = chunks.join(" ")
236
+ }
237
+ }
238
+
239
+ if (raiseIfInvalidOrDisabled && (!valid || !enabled)) {
240
+ throw new InvalidSource(lineIn)
241
+ }
242
+
243
+ return { valid, enabled, source, comment }
244
+ }
245
+
246
+ protected _expand_path(filename: string): string {
247
+ filename = filename.endsWith(".list") ? filename : `${filename}.list`
248
+ if (filename.includes("/")) return filename
249
+ return `${this.sources_dir.replace(/\/+$/, "")}/${filename}`
250
+ }
251
+
252
+ protected _suggest_filename(
253
+ line: string,
254
+ params?: { filename?: string }
255
+ ): string {
256
+ const cleanupFilename = (s: string) => {
257
+ const explicit = params?.filename
258
+ if (explicit != null) return explicit
259
+ return s
260
+ .replace(/[^a-zA-Z0-9]/g, " ")
261
+ .trim()
262
+ .split(/\s+/)
263
+ .join("_")
264
+ }
265
+ const stripUserPass = (s: string) => {
266
+ if (s.includes("@")) {
267
+ const parts = s.split("@")
268
+ return parts[parts.length - 1]
269
+ }
270
+ return s
271
+ }
272
+
273
+ let work = line.replace(/\[[^\]]+\]/g, "")
274
+ work = work.replace(/\w+:\/\//g, "")
275
+
276
+ const parts = work
277
+ .split(/\s+/)
278
+ .filter((p) => p && !VALID_SOURCE_TYPES.has(p))
279
+
280
+ if (parts.length > 0) {
281
+ parts[0] = stripUserPass(parts[0])
282
+ }
283
+
284
+ const base = cleanupFilename(parts.slice(0, 1).join(" "))
285
+ return `${base}.list`
286
+ }
287
+
288
+ protected _choice<T>(n: T | undefined | null, old: T): T {
289
+ return n == null ? old : n
290
+ }
291
+
292
+ protected _add_valid_source(
293
+ source_new: string,
294
+ comment_new: string,
295
+ file?: string | null
296
+ ) {
297
+ let found = false
298
+ for (const [filename, n, _enabled, src] of this) {
299
+ if (src === source_new) {
300
+ this.modify(filename, n, true)
301
+ found = true
302
+ }
303
+ }
304
+
305
+ if (!found) {
306
+ let targetFile: string
307
+ if (!file) {
308
+ targetFile = this.default_file
309
+ } else {
310
+ targetFile = this._expand_path(file)
311
+ }
312
+
313
+ if (!this.files[targetFile]) {
314
+ this.files[targetFile] = []
315
+ }
316
+ const list = this.files[targetFile]
317
+ list.push({
318
+ n: list.length,
319
+ valid: true,
320
+ enabled: true,
321
+ source: source_new,
322
+ comment: comment_new
323
+ })
324
+ this.new_repos.add(targetFile)
325
+ }
326
+ }
327
+
328
+ protected _remove_valid_source(source: string | undefined, regexp?: string) {
329
+ for (const [filename, n, enabled, src] of this) {
330
+ if (enabled) {
331
+ if (src === source || (regexp && new RegExp(regexp).test(src))) {
332
+ this.files[filename].splice(n, 1)
333
+ this.files[filename] = this.files[filename].map((e, idx) => ({
334
+ ...e,
335
+ n: idx
336
+ }))
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ *[Symbol.iterator](): IterableIterator<
343
+ [file: string, n: number, enabled: boolean, source: string, comment: string]
344
+ > {
345
+ for (const [file, sources] of Object.entries(this.files)) {
346
+ for (const entry of sources) {
347
+ if (entry.valid) {
348
+ yield [file, entry.n, entry.enabled, entry.source, entry.comment]
349
+ }
350
+ }
351
+ }
352
+ }
353
+
354
+ toJSON() {
355
+ return {
356
+ files: this.files,
357
+ files_map: this.files_map,
358
+ default_file: this.default_file,
359
+ sources_dir: this.sources_dir,
360
+ new_repos: Array.from(this.new_repos)
361
+ }
362
+ }
363
+ }