@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,358 @@
1
+ {
2
+ "$id": "https://katmer.dev/schemas/katmer-config.schema.json",
3
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
4
+ "title": "Katmer Configuration Schema",
5
+ "description": "Defines the structure of a Katmer configuration file used to describe hosts, groups, and logging settings.",
6
+ "type": "object",
7
+ "required": ["targets"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string",
12
+ "description": "Optional JSON Schema identifier to explicitly declare the schema version or custom dialect used by this configuration.",
13
+ "examples": [
14
+ "https://json-schema.org/draft/2020-12/schema",
15
+ "https://katmer.dev/schemas/katmer-config.schema.json"
16
+ ]
17
+ },
18
+ "$id": {
19
+ "type": "string",
20
+ "description": "Optional unique identifier (URI) for this configuration instance, useful for referencing or composition.",
21
+ "examples": [
22
+ "https://example.com/envs/prod.json",
23
+ "file:///etc/katmer/katmer.config.json"
24
+ ]
25
+ },
26
+ "cwd": {
27
+ "type": "string",
28
+ "description": "Working directory in which Katmer operations will be executed. Defaults to the current directory if omitted.",
29
+ "examples": ["./deploy", "/opt/katmer", "/home/admin/katmer-tasks"]
30
+ },
31
+ "logging": {
32
+ "type": "object",
33
+ "description": "Configuration options for logging.",
34
+ "properties": {
35
+ "dir": {
36
+ "type": "string",
37
+ "description": "Directory path where log files should be stored.",
38
+ "examples": ["./logs", "/var/log/katmer"]
39
+ },
40
+ "level": {
41
+ "type": "string",
42
+ "enum": ["trace", "debug", "info", "warn", "error", "silent"],
43
+ "description": "Defines the verbosity level of logs.",
44
+ "examples": ["info", "debug"]
45
+ }
46
+ },
47
+ "additionalProperties": false,
48
+ "examples": [
49
+ {
50
+ "dir": "./logs",
51
+ "level": "debug"
52
+ }
53
+ ]
54
+ },
55
+ "include": {
56
+ "description": "An optional file or remote URL to include additional configuration from.",
57
+ "anyOf": [
58
+ {
59
+ "type": "string",
60
+ "description": "A local or remote file path to another Katmer configuration.",
61
+ "examples": ["./common.yml", "https://katmer.dev/configs/base.json"]
62
+ },
63
+ {
64
+ "type": "object",
65
+ "required": ["url"],
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "url": {
69
+ "type": "string",
70
+ "description": "URL pointing to an external Katmer configuration file.",
71
+ "examples": ["https://katmer.dev/configs/shared.json"]
72
+ }
73
+ },
74
+ "examples": [{ "url": "https://katmer.dev/configs/base.json" }]
75
+ }
76
+ ]
77
+ },
78
+ "targets": {
79
+ "description": "Top-level mapping of named target groups. Each target defines one or more host groups.",
80
+ "anyOf": [
81
+ {
82
+ "type": "object",
83
+ "properties": {
84
+ "hosts": {
85
+ "$ref": "#/$defs/KatmerHostsDefinition",
86
+ "description": "Map of host definitions belonging to this group."
87
+ },
88
+ "variables": {
89
+ "type": "object",
90
+ "description": "Arbitrary data variables merged into ctx.variables for all hosts in this (root) group.",
91
+ "additionalProperties": true,
92
+ "examples": [{ "env": "prod", "app_dir": "/opt/myapp" }]
93
+ },
94
+ "environment": {
95
+ "type": "object",
96
+ "description": "Key/value pairs exported as environment variables for all hosts in this (root) group.",
97
+ "additionalProperties": {
98
+ "type": ["string", "number", "boolean"]
99
+ },
100
+ "examples": [
101
+ { "NODE_ENV": "production", "HTTP_PROXY": "http://proxy:8080" }
102
+ ]
103
+ }
104
+ }
105
+ },
106
+ {
107
+ "type": "object",
108
+ "patternProperties": {
109
+ "^[a-zA-Z0-9_.:-]+$": { "$ref": "#/$defs/KatmerHostGroup" }
110
+ },
111
+ "description": "Named target groups, where each key corresponds to a group identifier."
112
+ }
113
+ ],
114
+ "examples": [
115
+ {
116
+ "production": {
117
+ "variables": {
118
+ "env": "prod",
119
+ "app_dir": "/opt/myapp",
120
+ "release": "2024-10-18"
121
+ },
122
+ "environment": {
123
+ "NODE_ENV": "production",
124
+ "HTTP_PROXY": "http://proxy.internal:8080"
125
+ },
126
+ "hosts": {
127
+ "app-server-1": {
128
+ "connection": "ssh",
129
+ "hostname": "10.0.0.11",
130
+ "username": "deploy",
131
+ "private_key": "~/.ssh/id_rsa"
132
+ },
133
+ "app-server-2": {
134
+ "connection": "ssh",
135
+ "hostname": "10.0.0.12",
136
+ "username": "deploy",
137
+ "private_key": "~/.ssh/id_rsa"
138
+ }
139
+ },
140
+ "children": {
141
+ "db": {
142
+ "variables": { "pg_version": 15 },
143
+ "environment": { "PGTZ": "UTC" },
144
+ "hosts": {
145
+ "db1": {
146
+ "connection": "ssh",
147
+ "hostname": "10.0.0.20",
148
+ "username": "postgres",
149
+ "private_key": "~/.ssh/id_rsa"
150
+ }
151
+ }
152
+ }
153
+ }
154
+ },
155
+ "local": {
156
+ "hosts": {
157
+ "localhost": { "connection": "local" }
158
+ }
159
+ }
160
+ }
161
+ ]
162
+ }
163
+ },
164
+
165
+ "$defs": {
166
+ "KatmerHostGroup": {
167
+ "type": "object",
168
+ "description": "A collection of hosts and/or nested child groups. Groups can inherit settings from parent groups.",
169
+ "properties": {
170
+ "hosts": {
171
+ "$ref": "#/$defs/KatmerHostsDefinition",
172
+ "description": "Map of host definitions belonging to this group."
173
+ },
174
+ "variables": {
175
+ "type": "object",
176
+ "description": "Arbitrary data variables merged into ctx.variables for all hosts in this group.",
177
+ "additionalProperties": true,
178
+ "examples": [{ "env": "prod", "app_dir": "/opt/myapp" }]
179
+ },
180
+ "environment": {
181
+ "type": "object",
182
+ "description": "Key/value pairs exported as environment variables for all hosts in this group.",
183
+ "additionalProperties": { "type": ["string", "number", "boolean"] },
184
+ "examples": [
185
+ { "NODE_ENV": "production", "NO_PROXY": "localhost,127.0.0.1" }
186
+ ]
187
+ },
188
+ "children": {
189
+ "type": "object",
190
+ "description": "Nested child groups within this host group. Each child can be a group or null (for disabling inheritance).",
191
+ "patternProperties": {
192
+ "^[a-zA-Z0-9_.:-]+$": {
193
+ "oneOf": [
194
+ { "$ref": "#/$defs/KatmerHostGroup" },
195
+ { "type": "null" }
196
+ ]
197
+ }
198
+ },
199
+ "examples": [
200
+ {
201
+ "backend": {
202
+ "hosts": {
203
+ "api1": {
204
+ "connection": "ssh",
205
+ "hostname": "192.168.10.12",
206
+ "username": "ubuntu"
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ]
212
+ }
213
+ },
214
+ "examples": [
215
+ {
216
+ "variables": { "env": "staging" },
217
+ "environment": { "NODE_ENV": "staging" },
218
+ "hosts": {
219
+ "node1": { "connection": "ssh", "hostname": "10.0.1.1" },
220
+ "node2": { "connection": "ssh", "hostname": "10.0.1.2" }
221
+ }
222
+ }
223
+ ]
224
+ },
225
+
226
+ "KatmerHostsDefinition": {
227
+ "type": "object",
228
+ "description": "Mapping of host identifiers to their configuration objects.",
229
+ "patternProperties": {
230
+ "^[a-zA-Z0-9_.:-]+$": {
231
+ "oneOf": [
232
+ { "$ref": "#/$defs/KatmerHostConfig" },
233
+ {
234
+ "type": "null",
235
+ "description": "Explicitly disabled or undefined host entry."
236
+ }
237
+ ]
238
+ }
239
+ },
240
+ "additionalProperties": false,
241
+ "examples": [
242
+ {
243
+ "web1": {
244
+ "connection": "ssh",
245
+ "hostname": "10.1.0.5",
246
+ "username": "root"
247
+ },
248
+ "web2": null
249
+ }
250
+ ]
251
+ },
252
+
253
+ "KatmerHostConfig": {
254
+ "type": "object",
255
+ "description": "Connection configuration for an individual host.",
256
+ "required": ["connection"],
257
+ "properties": {
258
+ "connection": {
259
+ "type": "string",
260
+ "enum": ["ssh", "local"],
261
+ "description": "Connection type used for the host. Can be 'ssh' for remote or 'local' for local execution.",
262
+ "examples": ["ssh", "local"]
263
+ },
264
+ "hostname": {
265
+ "type": "string",
266
+ "description": "Host or IP address for SSH connections.",
267
+ "examples": ["192.168.0.10", "server.local"]
268
+ },
269
+ "port": {
270
+ "type": "integer",
271
+ "minimum": 1,
272
+ "maximum": 65535,
273
+ "description": "Port number used for SSH connections. Defaults to 22 if omitted.",
274
+ "examples": [22, 2222]
275
+ },
276
+ "username": {
277
+ "type": "string",
278
+ "description": "Username for authentication.",
279
+ "examples": ["root", "deploy", "ubuntu"]
280
+ },
281
+ "password": {
282
+ "type": ["string", "number", "null"],
283
+ "description": "Password or token for authentication. May be null if using key-based authentication.",
284
+ "examples": ["supersecret", null]
285
+ },
286
+ "private_key": {
287
+ "type": "string",
288
+ "description": "Path to a private key file for SSH authentication.",
289
+ "examples": ["~/.ssh/id_rsa", "/etc/keys/deploy.pem"]
290
+ },
291
+ "private_key_password": {
292
+ "type": "string",
293
+ "description": "Optional password for the private key file.",
294
+ "examples": ["keypass123"]
295
+ }
296
+ },
297
+ "additionalProperties": false,
298
+ "examples": [
299
+ {
300
+ "connection": "ssh",
301
+ "hostname": "10.0.0.11",
302
+ "port": 22,
303
+ "username": "deploy",
304
+ "private_key": "~/.ssh/id_rsa"
305
+ },
306
+ { "connection": "local" }
307
+ ]
308
+ }
309
+ },
310
+
311
+ "examples": [
312
+ {
313
+ "cwd": "./deploy",
314
+ "logging": { "dir": "./logs", "level": "info" },
315
+ "include": "https://katmer.dev/configs/shared.json",
316
+ "targets": {
317
+ "production": {
318
+ "variables": {
319
+ "env": "prod",
320
+ "app_dir": "/opt/myapp",
321
+ "release": "2024-10-18"
322
+ },
323
+ "environment": {
324
+ "NODE_ENV": "production",
325
+ "HTTP_PROXY": "http://proxy.internal:8080"
326
+ },
327
+ "hosts": {
328
+ "frontend": {
329
+ "connection": "ssh",
330
+ "hostname": "10.0.0.10",
331
+ "username": "deployer",
332
+ "private_key": "~/.ssh/id_rsa"
333
+ }
334
+ },
335
+ "children": {
336
+ "database": {
337
+ "variables": { "pg_version": 15 },
338
+ "environment": { "PGTZ": "UTC" },
339
+ "hosts": {
340
+ "db1": {
341
+ "connection": "ssh",
342
+ "hostname": "10.0.0.15",
343
+ "username": "postgres",
344
+ "private_key": "~/.ssh/id_rsa"
345
+ }
346
+ }
347
+ }
348
+ }
349
+ },
350
+ "local": {
351
+ "hosts": {
352
+ "localhost": { "connection": "local" }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ ]
358
+ }
@@ -0,0 +1,298 @@
1
+ import type {
2
+ KatmerConfig,
3
+ KatmerHostResolved,
4
+ KatmerHostInput
5
+ } from "./interfaces/config.interface"
6
+ import { toMerged } from "es-toolkit"
7
+ import type { KatmerProvider } from "./interfaces/provider.interface"
8
+ import objectHash from "stable-hash"
9
+ import { SSHProvider } from "./providers/ssh/ssh.provider"
10
+ import type { KatmerCore } from "./katmer"
11
+ import { LocalProvider } from "./providers/local.provider"
12
+ import { wildcardMatch } from "./utils/string.utils"
13
+ import { evalObjectVals, evalTemplate } from "./utils/renderer/renderer"
14
+
15
+ export class KatmerTargetResolver {
16
+ #providerCache = new Map<string, KatmerProvider>()
17
+
18
+ #allNames: Set<string>
19
+ #groups: Map<string, Set<string>>
20
+ #hosts: Map<string, KatmerHostResolved>
21
+ constructor(
22
+ private core: KatmerCore,
23
+ ...targets: KatmerConfig["targets"][]
24
+ ) {
25
+ const normalized = KatmerTargetResolver.normalizeHosts(
26
+ this.core.config.targets,
27
+ ...targets
28
+ )
29
+ this.#hosts = normalized.hosts
30
+ this.#groups = normalized.groups
31
+ this.#allNames = normalized.allNames
32
+ }
33
+
34
+ get hosts() {
35
+ return this.#hosts
36
+ }
37
+
38
+ async resolveProvider(opts: KatmerHostResolved): Promise<KatmerProvider> {
39
+ const key = objectHash(opts)
40
+
41
+ if (this.#providerCache.has(key)) {
42
+ return this.#providerCache.get(key)!
43
+ }
44
+ let provider: KatmerProvider
45
+ if (!opts.connection || opts.connection === "ssh") {
46
+ provider = new SSHProvider(opts)
47
+ } else if (opts.connection === "local") {
48
+ provider = new LocalProvider(opts)
49
+ } else {
50
+ throw new Error(`Unknown connection type: ${opts["connection"]}`)
51
+ }
52
+
53
+ provider.logger = this.core.logger.child({
54
+ provider: provider.constructor.name
55
+ })
56
+
57
+ if (opts.variables) {
58
+ provider.variables = toMerged(provider.variables, opts.variables)
59
+ }
60
+ if (opts.environment) {
61
+ const env = toMerged(provider.environment, opts.environment)
62
+
63
+ provider.environment = await evalObjectVals(env, {
64
+ // TODO: env context
65
+ })
66
+ }
67
+
68
+ this.#providerCache.set(key, provider)
69
+ return provider
70
+ }
71
+
72
+ resolveTargets(pattern: string) {
73
+ if (pattern === "all" || pattern === "*") {
74
+ return [...this.#hosts.values()]
75
+ }
76
+
77
+ const parts = pattern.split(/[:,]/)
78
+ const included = [] as string[]
79
+ const excluded = [] as string[]
80
+ const intersected = [] as string[]
81
+
82
+ for (const part of parts) {
83
+ if (part.startsWith("!")) {
84
+ const plainName = part.slice(1)
85
+ if (excluded.indexOf(plainName) === -1) {
86
+ excluded.push(plainName)
87
+ }
88
+ } else if (part.startsWith("@")) {
89
+ const plainName = part.slice(1)
90
+ if (intersected.indexOf(plainName) === -1) {
91
+ intersected.push(part.slice(1))
92
+ }
93
+ } else if (included.indexOf(part) === -1) {
94
+ const token = part === "all" ? "*" : part // ← normalize 'all'
95
+ if (!included.includes(token)) included.push(token)
96
+ }
97
+ }
98
+ const wildcard = (name: string, pat: string) => wildcardMatch(name, pat)
99
+ const matchesAny = (name: string, list: string[]) =>
100
+ list.some((p) => wildcard(name, p))
101
+ const isExcluded = (name: string) => excluded.some((p) => wildcard(name, p))
102
+
103
+ // Stage 1: choose label candidates (hosts or groups), honoring exclusion
104
+ const candidateLabels = new Set<string>()
105
+ for (const name of this.#allNames) {
106
+ if (isExcluded(name)) continue
107
+ if (included.length === 0 || matchesAny(name, included)) {
108
+ candidateLabels.add(name)
109
+ }
110
+ }
111
+
112
+ // Stage 2: expand labels to hostnames, honoring exclusion & dedupe
113
+ const expandedHostnames = new Set<string>()
114
+ for (const label of candidateLabels) {
115
+ if (this.#groups.has(label)) {
116
+ for (const host of this.#groups.get(label)!) {
117
+ if (!isExcluded(host)) expandedHostnames.add(host)
118
+ }
119
+ } else if (this.#hosts.has(label)) {
120
+ if (!isExcluded(label)) expandedHostnames.add(label)
121
+ }
122
+ }
123
+
124
+ // Stage 3: optional intersection (@foo) applied on final hostnames
125
+ let finalHostnames = Array.from(expandedHostnames)
126
+ if (intersected.length > 0) {
127
+ finalHostnames = finalHostnames.filter((h) => matchesAny(h, intersected))
128
+ }
129
+
130
+ const resolved = finalHostnames
131
+ .map((h) => this.#hosts.get(h)!)
132
+ .filter(Boolean)
133
+
134
+ if (resolved.length === 0) {
135
+ throw new Error(`No targets found for pattern: ${pattern}`)
136
+ }
137
+ return resolved
138
+ }
139
+
140
+ static normalizeHosts(...inputs: KatmerConfig["targets"][]) {
141
+ const reservedKeys = [
142
+ "all",
143
+ "children",
144
+ "settings",
145
+ "variables",
146
+ "environment",
147
+ "hosts"
148
+ ]
149
+ const allNames = new Set<string>()
150
+ const hosts = new Map<string, KatmerHostResolved>()
151
+ const groups = new Map<string, Set<string>>()
152
+
153
+ const groupSettingsAccum = new Map<string, Record<string, any>>()
154
+ const groupVariablesAccum = new Map<string, Record<string, any>>()
155
+ const groupEnvAccum = new Map<string, Record<string, any>>() // NEW
156
+
157
+ for (const input of inputs) {
158
+ if (!input) continue
159
+
160
+ const rootKeys = Object.keys(input)
161
+ const isHostsDef =
162
+ rootKeys.includes("hosts") ||
163
+ rootKeys.includes("settings") ||
164
+ rootKeys.includes("variables") ||
165
+ rootKeys.includes("environment") // NEW
166
+
167
+ if (isHostsDef) {
168
+ // Ungrouped (root) settings
169
+ const incomingSettings = (input as any).settings || {}
170
+ const prevSettings = groupSettingsAccum.get("ungrouped") || {}
171
+ const effSettings = toMerged(prevSettings, incomingSettings) as any
172
+ groupSettingsAccum.set("ungrouped", effSettings)
173
+
174
+ // Ungrouped variables
175
+ const incomingVars = (input as any).variables || {}
176
+ const prevVars = groupVariablesAccum.get("ungrouped") || {}
177
+ const effVars = toMerged(prevVars, incomingVars) as any
178
+ groupVariablesAccum.set("ungrouped", effVars)
179
+
180
+ // Ungrouped environment (NEW)
181
+ const incomingEnv = (input as any).environment || {}
182
+ const prevEnv = groupEnvAccum.get("ungrouped") || {}
183
+ const effEnv = toMerged(prevEnv, incomingEnv) as any
184
+ groupEnvAccum.set("ungrouped", effEnv)
185
+
186
+ processHostEntries(
187
+ (input as any).hosts,
188
+ "ungrouped",
189
+ effSettings,
190
+ effVars,
191
+ effEnv
192
+ )
193
+ } else {
194
+ for (const [groupName, def] of Object.entries(input)) {
195
+ if (reservedKeys.includes(groupName)) {
196
+ throw `cannot use '${groupName}' as group name: it is a reserved keyword`
197
+ }
198
+
199
+ // Group settings
200
+ const incomingSettings = (def as any)?.settings || {}
201
+ const prevSettings = groupSettingsAccum.get(groupName) || {}
202
+ const effSettings = toMerged(prevSettings, incomingSettings) as any
203
+ groupSettingsAccum.set(groupName, effSettings)
204
+
205
+ // Group variables
206
+ const incomingVars = (def as any)?.variables || {}
207
+ const prevVars = groupVariablesAccum.get(groupName) || {}
208
+ const effVars = toMerged(prevVars, incomingVars) as any
209
+ groupVariablesAccum.set(groupName, effVars)
210
+
211
+ // Group environment (NEW)
212
+ const incomingEnv = (def as any)?.environment || {}
213
+ const prevEnv = groupEnvAccum.get(groupName) || {}
214
+ const effEnv = toMerged(prevEnv, incomingEnv) as any
215
+ groupEnvAccum.set(groupName, effEnv)
216
+
217
+ processHostEntries(
218
+ def?.hosts,
219
+ groupName,
220
+ effSettings,
221
+ effVars,
222
+ effEnv
223
+ )
224
+
225
+ // Link children: inherit parent *settings/variables/environment* to child hosts
226
+ const childNames = Object.keys(def?.children || {})
227
+ for (const childGroupName of childNames) {
228
+ if (!groups.has(childGroupName)) {
229
+ throw `child group not found: '${childGroupName}' in group: '${groupName}'`
230
+ }
231
+ for (const child_hostname of groups.get(childGroupName)!) {
232
+ const prevChild = (hosts.get(child_hostname) || {}) as any
233
+ const childVarsPrev = prevChild.variables || {}
234
+ const childEnvPrev = prevChild.environment || {}
235
+ const parentVars = groupVariablesAccum.get(groupName) || {}
236
+ const parentEnv = groupEnvAccum.get(groupName) || {}
237
+
238
+ hosts.set(child_hostname, {
239
+ ...(toMerged(prevChild, effSettings) as any),
240
+ name: child_hostname,
241
+ variables: toMerged(childVarsPrev, parentVars),
242
+ environment: toMerged(childEnvPrev, parentEnv) // NEW
243
+ })
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+
250
+ function processHostEntries(
251
+ hostConfig?: Record<string, KatmerHostInput>,
252
+ groupName: string = "ungrouped",
253
+ groupSettings: Record<string, any> = {},
254
+ groupVariables: Record<string, any> = {},
255
+ groupEnvironment: Record<string, any> = {} // NEW
256
+ ) {
257
+ if (!groups.has(groupName)) groups.set(groupName, new Set<string>())
258
+ const groupHosts = groups.get(groupName)!
259
+ allNames.add(groupName)
260
+
261
+ for (const [hostname, host_settings] of Object.entries(
262
+ hostConfig || {}
263
+ )) {
264
+ if (reservedKeys.includes(hostname)) {
265
+ throw `cannot use '${hostname}' as hostname: it is a reserved keyword`
266
+ }
267
+ allNames.add(hostname)
268
+ groupHosts.add(hostname)
269
+
270
+ const prev = (hosts.get(hostname) || {}) as any
271
+ const mergedConn = toMerged(
272
+ prev,
273
+ toMerged(groupSettings || {}, host_settings || {})
274
+ ) as any
275
+
276
+ const prevVars = prev.variables || {}
277
+ const prevEnv = prev.environment || {}
278
+
279
+ hosts.set(hostname, {
280
+ ...mergedConn,
281
+ name: hostname,
282
+ variables: toMerged(prevVars, groupVariables || {}),
283
+ environment: toMerged(prevEnv, groupEnvironment || {}) // NEW
284
+ })
285
+ }
286
+ }
287
+
288
+ return { groups, hosts, allNames }
289
+ }
290
+
291
+ async [Symbol.asyncDispose]() {
292
+ try {
293
+ for (const [_hash, provider] of this.#providerCache.entries()) {
294
+ await provider.safeShutdown()
295
+ }
296
+ } catch {}
297
+ }
298
+ }
@@ -0,0 +1,42 @@
1
+ import type { KatmerTask } from "../task"
2
+ import type { Katmer } from "../../interfaces/task.interface"
3
+ import { evalExpr, evalObjectVals } from "../../utils/renderer/renderer"
4
+ import { merge } from "es-toolkit/compat"
5
+ import { mapValues } from "es-toolkit"
6
+
7
+ const configKey = "environment" as const
8
+
9
+ export const EnvironmentControl = {
10
+ order: 10,
11
+ configKey,
12
+ register(task: KatmerTask, cfg?: Katmer.TaskRule[typeof configKey]) {
13
+ task.on("before:execute", async (_task, ctx) => {
14
+ if (cfg || Object.keys(ctx.provider.environment).length > 0) {
15
+ const _exec = ctx.exec
16
+
17
+ ctx.exec = async (command, options) => {
18
+ const taskEnv =
19
+ typeof cfg === "string" ? await evalExpr(cfg, ctx.variables) : cfg
20
+
21
+ const env = mapValues(
22
+ merge(
23
+ {},
24
+ ctx.provider.environment,
25
+ taskEnv || {},
26
+ options?.env || {}
27
+ ),
28
+ (value) =>
29
+ value !== undefined && value !== null ? String(value) : value
30
+ )
31
+
32
+ return _exec(
33
+ command,
34
+ merge({}, options, {
35
+ env: await evalObjectVals(env, ctx.variables)
36
+ })
37
+ )
38
+ }
39
+ }
40
+ })
41
+ }
42
+ }