@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,435 @@
|
|
|
1
|
+
import type { Katmer } from "../../interfaces/task.interface"
|
|
2
|
+
import { SourcesList } from "./apt-sources-list"
|
|
3
|
+
import { cloneInstance } from "../../utils/object.utils"
|
|
4
|
+
import type { SSHProvider } from "../../providers/ssh/ssh.provider"
|
|
5
|
+
import { KatmerModule } from "../../module"
|
|
6
|
+
|
|
7
|
+
declare module "../../interfaces/task.interface" {
|
|
8
|
+
export namespace Katmer {
|
|
9
|
+
export interface TaskActions {
|
|
10
|
+
/**
|
|
11
|
+
* Manage apt repositories (.list files).
|
|
12
|
+
* See {@link AptRepositoryModuleOptions | AptRepositoryModuleOptions} for all parameters.
|
|
13
|
+
*/
|
|
14
|
+
apt_repository?: AptRepositoryModuleOptions
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export interface AptRepositoryModuleOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Desired end-state for the repository line(s).
|
|
21
|
+
*
|
|
22
|
+
* - `"present"`: ensure the given `repo` line(s) exist (create/update files as needed).
|
|
23
|
+
* - `"absent"`: remove matching lines (by exact `repo` or by `regexp`).
|
|
24
|
+
*
|
|
25
|
+
* @defaultValue "present"
|
|
26
|
+
*/
|
|
27
|
+
state?: "present" | "absent"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Repository line(s) to add or remove.
|
|
31
|
+
*
|
|
32
|
+
* - **Required** when `state === "present"`.
|
|
33
|
+
* - **Optional** when `state === "absent"`; you may use this to remove specific line(s), or omit it and use `regexp` to remove by pattern.
|
|
34
|
+
*/
|
|
35
|
+
repo?: string | string[]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Regular expression to match lines for removal (used only with `state === "absent"`).
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* - Mutually exclusive with `repo` for `state === "present"`.
|
|
42
|
+
* - Useful to sweep multiple entries across files (e.g., remove a discontinued mirror).
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* "^deb .*example\\.com"
|
|
46
|
+
*/
|
|
47
|
+
regexp?: string
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Override the destination filename (no path).
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* - When adding lines, places them under `/etc/apt/sources.list.d/[filename].list`.
|
|
54
|
+
* - If omitted, the module chooses a suitable filename (e.g. derived from the repo).
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* "example.list"
|
|
58
|
+
*/
|
|
59
|
+
filename?: string
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run `apt-get update` after changes.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* - Only runs if any file content actually changed.
|
|
66
|
+
* - Retries are controlled by {@link AptRepositoryModuleOptions.update_cache_retries} and {@link AptRepositoryModuleOptions.update_cache_retry_max_delay}.
|
|
67
|
+
*
|
|
68
|
+
* @defaultValue false
|
|
69
|
+
*/
|
|
70
|
+
update_cache?: boolean
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Maximum retry attempts when updating the APT cache.
|
|
74
|
+
*
|
|
75
|
+
* @defaultValue 5
|
|
76
|
+
*/
|
|
77
|
+
update_cache_retries?: number
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Maximum exponential backoff (seconds) between retries when updating the cache.
|
|
81
|
+
*
|
|
82
|
+
* @defaultValue 12
|
|
83
|
+
*/
|
|
84
|
+
update_cache_retry_max_delay?: number
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate TLS certificates when fetching remote keys/sources.
|
|
88
|
+
*
|
|
89
|
+
* @defaultValue true
|
|
90
|
+
*/
|
|
91
|
+
validate_certs?: boolean
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Simulate changes without writing files or running `apt-get update`.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* - Returns what would change; useful in CI and dry runs.
|
|
98
|
+
*
|
|
99
|
+
* @defaultValue false
|
|
100
|
+
*/
|
|
101
|
+
check_mode?: boolean
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* File mode for created/updated source files.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* - Accepts octal number (e.g., `0o644`) or string (e.g., `"0644"`).
|
|
108
|
+
* - Only applied to files the module creates/updates.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* "0644"
|
|
112
|
+
* @example
|
|
113
|
+
* 0o644
|
|
114
|
+
*/
|
|
115
|
+
mode?: number | string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Result returned by the `apt-repository` module.
|
|
120
|
+
* @public
|
|
121
|
+
*/
|
|
122
|
+
export interface AptRepositoryModuleResult {
|
|
123
|
+
/** Original repo parameter (if provided). */
|
|
124
|
+
repo?: string | string[]
|
|
125
|
+
/** Filenames created. */
|
|
126
|
+
sources_added: string[]
|
|
127
|
+
/** Filenames removed. */
|
|
128
|
+
sources_removed: string[]
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Manage entries in APT sources lists (e.g. `/etc/apt/sources.list.d/*.list`).
|
|
132
|
+
*
|
|
133
|
+
* @remarks
|
|
134
|
+
* - When `state: "present"`, at least one `repo` line is required.
|
|
135
|
+
* - When `state: "absent"`, you may remove by explicit `repo` line(s) or by `regexp`.
|
|
136
|
+
* - For `state: "present"`, do **not** set `regexp` (mutually exclusive with `repo`).
|
|
137
|
+
* - If `update_cache` is true and changes occurred, `apt-get update` will run with retries/backoff.
|
|
138
|
+
*
|
|
139
|
+
* @examples
|
|
140
|
+
* ```yaml
|
|
141
|
+
* - name: Add a repo line and refresh cache
|
|
142
|
+
* apt-repository:
|
|
143
|
+
* state: present
|
|
144
|
+
* repo: "deb http://deb.debian.org/debian bookworm main"
|
|
145
|
+
* update_cache: true
|
|
146
|
+
*
|
|
147
|
+
* - name: Remove specific repo lines
|
|
148
|
+
* apt-repository:
|
|
149
|
+
* state: absent
|
|
150
|
+
* repo:
|
|
151
|
+
* - "deb http://old.example.com/debian bookworm main"
|
|
152
|
+
* - "deb-src http://old.example.com/debian bookworm main"
|
|
153
|
+
*
|
|
154
|
+
* - name: Remove all lines that match a pattern
|
|
155
|
+
* apt-repository:
|
|
156
|
+
* state: absent
|
|
157
|
+
* regexp: "^deb .*example\\.com"
|
|
158
|
+
*
|
|
159
|
+
* - name: Add deb + deb-src into a fixed filename
|
|
160
|
+
* apt-repository:
|
|
161
|
+
* state: present
|
|
162
|
+
* filename: "example.list"
|
|
163
|
+
* repo:
|
|
164
|
+
* - "deb http://deb.debian.org/debian bookworm main"
|
|
165
|
+
* - "deb-src http://deb.debian.org/debian bookworm main"
|
|
166
|
+
* mode: "0644"
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
export class AptRepositoryModule extends KatmerModule<
|
|
171
|
+
AptRepositoryModuleOptions,
|
|
172
|
+
AptRepositoryModuleResult,
|
|
173
|
+
SSHProvider
|
|
174
|
+
> {
|
|
175
|
+
static name = "apt-repository" as const
|
|
176
|
+
|
|
177
|
+
constraints = {
|
|
178
|
+
platform: {
|
|
179
|
+
linux: {
|
|
180
|
+
packages: ["apt"]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
apt_config!: Record<string, any>
|
|
185
|
+
sources_list!: SourcesList
|
|
186
|
+
|
|
187
|
+
async check(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
|
|
188
|
+
if (this.params.state === "present" && (this.params as any).regexp) {
|
|
189
|
+
throw "'regexp' is not supported with state: 'present'"
|
|
190
|
+
}
|
|
191
|
+
if ((this.params as any).regexp && (this.params as any).repo) {
|
|
192
|
+
throw "'regexp' and 'repo' cannot be used together"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const checkModules = ["apt-get", "apt-config"]
|
|
196
|
+
for (const module of checkModules) {
|
|
197
|
+
const checkResult = await ctx.execSafe(
|
|
198
|
+
`command -v ${module} >/dev/null 2>&1; echo $?`
|
|
199
|
+
)
|
|
200
|
+
if (String(checkResult.stdout).trim() !== "0") {
|
|
201
|
+
throw new Error(`${module} is not available on the target system.`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { stdout } = await ctx.exec("apt-config dump")
|
|
206
|
+
this.apt_config = parseAPTConfig(stdout)
|
|
207
|
+
|
|
208
|
+
// validate repo lines only for "present"
|
|
209
|
+
const state = this.params.state ?? "present"
|
|
210
|
+
if (state === "present") {
|
|
211
|
+
const repo = this.params.repo
|
|
212
|
+
const repos = Array.isArray(repo) ? repo : [repo]
|
|
213
|
+
const first = repos.find((r) => typeof r === "string" && r.trim())
|
|
214
|
+
if (!first) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"Invalid configuration: 'repo' must be a non-empty string or string[]"
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
const firstToken = first
|
|
220
|
+
.replace(/\[[^\]]*\]/g, "")
|
|
221
|
+
.trim()
|
|
222
|
+
.split(/\s+/)[0]
|
|
223
|
+
if (!/^deb(-src)?$/.test(firstToken)) {
|
|
224
|
+
throw new Error("Repository line must start with 'deb' or 'deb-src'")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async initialize(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
|
|
230
|
+
this.sources_list = new SourcesList(this.apt_config, ctx)
|
|
231
|
+
await this.sources_list.init()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
|
|
235
|
+
return Promise.resolve(undefined)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
protected async _revert_sources_list(
|
|
239
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
240
|
+
sources_before: Record<string, string>,
|
|
241
|
+
sources_after: Record<string, string>,
|
|
242
|
+
initial_sources_list: SourcesList
|
|
243
|
+
) {
|
|
244
|
+
try {
|
|
245
|
+
const beforeKeys = new Set(Object.keys(sources_before))
|
|
246
|
+
const afterKeys = new Set(Object.keys(sources_after))
|
|
247
|
+
|
|
248
|
+
// remove files that didn't exist before
|
|
249
|
+
for (const added of Object.keys(sources_after)) {
|
|
250
|
+
if (!beforeKeys.has(added)) {
|
|
251
|
+
await ctx.exec(`rm -f -- ${JSON.stringify(added)}`).catch(() => {})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// restore only files that existed before AND whose contents actually changed
|
|
256
|
+
for (const filename of Object.keys(sources_before)) {
|
|
257
|
+
if (!afterKeys.has(filename)) continue
|
|
258
|
+
const beforeContent = sources_before[filename] ?? ""
|
|
259
|
+
const afterContent = sources_after[filename] ?? ""
|
|
260
|
+
if (beforeContent !== afterContent) {
|
|
261
|
+
const payload = beforeContent.replace(/'/g, `'\"'\"'`)
|
|
262
|
+
const tmp = `${filename}.katmer-revert.$RANDOM$$`
|
|
263
|
+
const writeCmd = `'umask 022; tmp=${JSON.stringify(tmp)}; target=${JSON.stringify(
|
|
264
|
+
filename
|
|
265
|
+
)}; printf %s '${payload}' > "$tmp" && mv -f "$tmp" "$target"`
|
|
266
|
+
await ctx.exec(writeCmd).catch(() => {})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// ignore revert failures
|
|
271
|
+
} finally {
|
|
272
|
+
await initial_sources_list.save()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async execute(ctx: Katmer.TaskContext<SSHProvider>) {
|
|
277
|
+
const state = this.params.state ?? "present"
|
|
278
|
+
const update_cache = !!this.params.update_cache
|
|
279
|
+
|
|
280
|
+
const sources_list = this.sources_list
|
|
281
|
+
const initial_sources_list = cloneInstance(sources_list)
|
|
282
|
+
const sources_before = sources_list.dump()
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
if (state === "present") {
|
|
286
|
+
const { repo, filename } = this.params
|
|
287
|
+
const fname = filename?.trim() || undefined
|
|
288
|
+
const repos = Array.isArray(repo) ? repo : [repo]
|
|
289
|
+
for (const r of repos) {
|
|
290
|
+
if (!r?.trim()) continue
|
|
291
|
+
this.sources_list.add_source(r, "", fname)
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
const { repo, regexp } = this.params
|
|
295
|
+
const repos = Array.isArray(repo) ? repo : [repo]
|
|
296
|
+
if (repos.length > 0) {
|
|
297
|
+
for (const r of repos) {
|
|
298
|
+
if (!r) continue
|
|
299
|
+
sources_list.remove_source(r, undefined)
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
// fallback to regexp removal when repo not provided
|
|
303
|
+
sources_list.remove_source(undefined, regexp)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch (ex: any) {
|
|
307
|
+
throw new Error(`Invalid repository string: ${String(ex?.message ?? ex)}`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const sources_after = sources_list.dump()
|
|
311
|
+
const changed =
|
|
312
|
+
JSON.stringify(sources_before) !== JSON.stringify(sources_after)
|
|
313
|
+
|
|
314
|
+
let diff: Array<{
|
|
315
|
+
before: string
|
|
316
|
+
after: string
|
|
317
|
+
before_header: string
|
|
318
|
+
after_header: string
|
|
319
|
+
}> = []
|
|
320
|
+
let sources_added: string[] = []
|
|
321
|
+
let sources_removed: string[] = []
|
|
322
|
+
|
|
323
|
+
if (changed) {
|
|
324
|
+
const beforeKeys = new Set(Object.keys(sources_before))
|
|
325
|
+
const afterKeys = new Set(Object.keys(sources_after))
|
|
326
|
+
|
|
327
|
+
sources_added = [...afterKeys].filter((k) => !beforeKeys.has(k))
|
|
328
|
+
sources_removed = [...beforeKeys].filter((k) => !afterKeys.has(k))
|
|
329
|
+
|
|
330
|
+
const union = new Set<string>([
|
|
331
|
+
...sources_added,
|
|
332
|
+
...sources_removed,
|
|
333
|
+
...Object.keys(sources_before).filter((k) => afterKeys.has(k))
|
|
334
|
+
])
|
|
335
|
+
diff = [...union]
|
|
336
|
+
.filter(
|
|
337
|
+
(filename) =>
|
|
338
|
+
(sources_before[filename] ?? "") !== (sources_after[filename] ?? "")
|
|
339
|
+
)
|
|
340
|
+
.map((filename) => ({
|
|
341
|
+
before: sources_before[filename] ?? "",
|
|
342
|
+
after: sources_after[filename] ?? "",
|
|
343
|
+
before_header: sources_before[filename] ? filename : "/dev/null",
|
|
344
|
+
after_header: sources_after[filename] ? filename : "/dev/null"
|
|
345
|
+
}))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (changed && !this.params.check_mode) {
|
|
349
|
+
try {
|
|
350
|
+
// save() will write only changed files and delete only those that became empty
|
|
351
|
+
await sources_list.save()
|
|
352
|
+
|
|
353
|
+
if (update_cache) {
|
|
354
|
+
const retries = this.params.update_cache_retries ?? 5
|
|
355
|
+
const maxDelay = this.params.update_cache_retry_max_delay ?? 12
|
|
356
|
+
const randomize = Math.random()
|
|
357
|
+
|
|
358
|
+
let success = false
|
|
359
|
+
let lastErr = ""
|
|
360
|
+
|
|
361
|
+
for (let retry = 0; retry < retries; retry++) {
|
|
362
|
+
const r = await ctx.execSafe("sudo apt-get update -y")
|
|
363
|
+
if (r.code === 0) {
|
|
364
|
+
success = true
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
lastErr = r.stderr || r.stdout || "unknown reason"
|
|
368
|
+
ctx.warn(
|
|
369
|
+
`Failed to update cache after ${retry + 1} due to ${lastErr} retry, retrying`
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
let delay = 2 ** retry + randomize
|
|
373
|
+
if (delay > maxDelay) delay = maxDelay + randomize
|
|
374
|
+
ctx.warn(
|
|
375
|
+
`Sleeping for ${Math.round(delay)} seconds, before attempting to update the cache again`
|
|
376
|
+
)
|
|
377
|
+
await new Promise((res) =>
|
|
378
|
+
setTimeout(res, Math.round(delay * 1000))
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!success) {
|
|
383
|
+
ctx.fail(
|
|
384
|
+
`Failed to update apt cache after ${retries} retries: ${lastErr}`
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
await this._revert_sources_list(
|
|
390
|
+
ctx,
|
|
391
|
+
sources_before,
|
|
392
|
+
sources_after,
|
|
393
|
+
initial_sources_list
|
|
394
|
+
)
|
|
395
|
+
throw e
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
changed,
|
|
401
|
+
repo: (this.params as any).repo,
|
|
402
|
+
state,
|
|
403
|
+
sources_added,
|
|
404
|
+
sources_removed,
|
|
405
|
+
diff
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseAPTConfig(raw: string) {
|
|
411
|
+
const result: Record<string, string | string[]> = {}
|
|
412
|
+
|
|
413
|
+
const lines = raw
|
|
414
|
+
.split(/\r?\n/)
|
|
415
|
+
.map((l) => l.trim())
|
|
416
|
+
.filter(Boolean)
|
|
417
|
+
|
|
418
|
+
for (const line of lines) {
|
|
419
|
+
const m = line.match(/^(.+?)\s+"?(.*?)"?;$/)
|
|
420
|
+
if (!m) continue
|
|
421
|
+
|
|
422
|
+
const rawKey = m[1].trim() // e.g., Dir::Etc::sourcelist
|
|
423
|
+
const value = m[2]
|
|
424
|
+
|
|
425
|
+
const current = result[rawKey]
|
|
426
|
+
if (current === undefined) {
|
|
427
|
+
result[rawKey] = value
|
|
428
|
+
} else if (Array.isArray(current)) {
|
|
429
|
+
current.push(value)
|
|
430
|
+
} else {
|
|
431
|
+
result[rawKey] = [current, value]
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return result
|
|
435
|
+
}
|