@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,807 @@
1
+ import path from "node:path"
2
+ import os from "node:os"
3
+ import crypto from "node:crypto"
4
+ import fs from "fs-extra"
5
+ import {
6
+ type ModuleCommonReturn,
7
+ type ModuleConstraints
8
+ } from "../interfaces/module.interface"
9
+ import type { Katmer } from "../katmer"
10
+ import type { KatmerProvider } from "../interfaces/provider.interface"
11
+ import { SSHProvider } from "../providers/ssh/ssh.provider"
12
+ import { LocalProvider } from "../providers/local.provider"
13
+ import { evalTemplate } from "../utils/renderer/renderer"
14
+ import { exec as execCb } from "node:child_process"
15
+ import { promisify } from "node:util"
16
+ import { toOctal } from "../utils/number.utils"
17
+ import { UnixComms } from "../utils/unix.utils"
18
+ import { WindowsComms } from "../utils/windows.utils"
19
+ import { KatmerModule } from "../module"
20
+
21
+ const exec = promisify(execCb)
22
+
23
+ const REMOTE_STAGE_DIR = "/tmp"
24
+
25
+ declare module "../interfaces/task.interface" {
26
+ export namespace Katmer {
27
+ export interface TaskActions {
28
+ copy?: CopyModuleOptions
29
+ }
30
+ }
31
+ }
32
+ /**
33
+ * Options for the {@link CopyModule | `copy`} module.
34
+ *
35
+ * Copies **content** or a **file** to a destination path.
36
+ *
37
+ * - If {@link CopyModuleOptions.content | `content`} is set, it is rendered **locally** using Twig
38
+ * against `ctx.variables` and written to {@link CopyModuleOptions.dest | `dest`}.
39
+ * - If {@link CopyModuleOptions.src | `src`} is set:
40
+ * - With **SSH** provider, it is treated as a **controller (local)** path and uploaded to the remote host,
41
+ * unless {@link CopyModuleOptions.remote_src | `remote_src`} is `true`.
42
+ * - With **Local** provider, it is a path on the **same machine**.
43
+ * - If {@link CopyModuleOptions.remote_src | `remote_src`} is `true` (SSH only), the copy happens **remote→remote** using shell.
44
+ * - If both `src` and `content` are set, the task fails.
45
+ *
46
+ * @public
47
+ */
48
+ export interface CopyModuleOptions {
49
+ /**
50
+ * Source path.
51
+ * - **SSH** (default): controller → remote upload (uses NodeSSH).
52
+ * - **SSH + `remote_src: true`**: remote → remote via shell (`cp`).
53
+ * - **Local**: local → local.
54
+ */
55
+ src?: string
56
+
57
+ /**
58
+ * Inline content to write (templated).
59
+ * If a `string`, it is rendered using Twig; if a `Uint8Array`, it is written raw.
60
+ * Mutually exclusive with {@link CopyModuleOptions.src | `src`}.
61
+ */
62
+ content?: string | Uint8Array
63
+
64
+ /**
65
+ * Destination path (absolute recommended).
66
+ * - **SSH**: path on the target host.
67
+ * - **Local**: local filesystem path.
68
+ */
69
+ dest: string
70
+
71
+ /**
72
+ * Ensure parent directories exist before writing.
73
+ * - **SSH**: `mkdir -p`
74
+ * - **Local**: `fs.ensureDir`
75
+ * @defaultValue true
76
+ */
77
+ parents?: boolean
78
+
79
+ /**
80
+ * Treat {@link CopyModuleOptions.src | `src`} as a **remote** path and perform remote→remote copy.
81
+ * Only valid for **SSH** provider.
82
+ * @defaultValue false
83
+ */
84
+ remote_src?: boolean
85
+
86
+ /**
87
+ * If `true`, overwrite even when content is identical (still creates backup if requested).
88
+ * When `false`, the module is idempotent and avoids rewriting unchanged files.
89
+ * @defaultValue false
90
+ */
91
+ force?: boolean
92
+
93
+ /**
94
+ * When `true`, saves a timestamped backup of the existing `dest` before overwrite.
95
+ * The resulting path is returned in {@link CopyModuleResult.backup_file | `backup_file`}.
96
+ * @defaultValue false
97
+ */
98
+ backup?: boolean
99
+
100
+ /**
101
+ * Validate the **staged temporary file** before placing it.
102
+ * The command is executed against the staged file:
103
+ * - If the template contains `%s`, it is replaced with the temp filename.
104
+ * - Otherwise the temp filename is appended as the last argument.
105
+ *
106
+ * Only after a **zero exit code** will the temp file be moved to `dest`.
107
+ *
108
+ * @example
109
+ * Validate an nginx config before replacing:
110
+ * ```yaml
111
+ * copy:
112
+ * src: ./nginx.conf
113
+ * dest: /etc/nginx/nginx.conf
114
+ * validate: "nginx -t -c %s"
115
+ * ```
116
+ */
117
+ validate?: string
118
+
119
+ /**
120
+ * File mode for `dest`. Accepts octal `string` (e.g. `"0644"`) or number (e.g. `0o644`).
121
+ * - **SSH**: applied via `chmod`.
122
+ * - **Local**: applied via `fs.chmod`.
123
+ */
124
+ mode?: string | number
125
+
126
+ /**
127
+ * File owner for `dest` (`chown owner[:group]`).
128
+ * - **SSH**: may be a name (e.g. `"root"`).
129
+ * - **Local**: POSIX only; names are resolved via `/etc/passwd`, otherwise numeric `uid` is expected.
130
+ */
131
+ owner?: string
132
+
133
+ /**
134
+ * File group for `dest` (`chown :group`).
135
+ * - **SSH**: may be a name (e.g. `"www-data"`).
136
+ * - **Local**: POSIX only; names are resolved via `/etc/group`, otherwise numeric `gid` is expected.
137
+ */
138
+ group?: string
139
+ }
140
+
141
+ /**
142
+ * Result returned by the {@link CopyModule | `copy`} module.
143
+ *
144
+ * @public
145
+ */
146
+ export interface CopyModuleResult extends ModuleCommonReturn {
147
+ /** Destination path written/examined. */
148
+ dest: string
149
+ /** SHA-256 hex digest of the final file, when determinable. */
150
+ checksum?: string
151
+ /** Backup file path when `backup: true` and an overwrite occurred. */
152
+ backup_file?: string
153
+ /** Whether the `validate` command was executed and passed. */
154
+ validated?: boolean
155
+ }
156
+
157
+ /**
158
+ * Copy files or inline content to a destination, with idempotency, optional validation,
159
+ * and metadata management (mode/owner/group).
160
+ *
161
+ * @remarks
162
+ * **Behavior by provider**
163
+ * - **SSH**:
164
+ * - Ensures parent directory with `mkdir -p` when `parents: true`.
165
+ * - Stages to a remote temp file (upload or `cp` for remote_src), validates, then moves into place atomically.
166
+ * - Applies `chmod`/`chown` via shell when requested.
167
+ * - Computes SHA-256 using `sha256sum`/`shasum -a 256` if available.
168
+ * - **Local**:
169
+ * - Uses pure Node.js (`fs.ensureDir`, `fs.move`, `fs.chmod`, `fs.chown`).
170
+ * - Owner/group resolution reads `/etc/passwd` and `/etc/group` (POSIX).
171
+ * - Computes SHA-256 by streaming the file via Node crypto.
172
+ *
173
+ * **Idempotency**
174
+ * - When `force: false` (default), compares the incoming staged file hash with the current `dest` hash.
175
+ * If equal, the write is skipped and `changed=false`.
176
+ *
177
+ * **Validation**
178
+ * - If {@link CopyModuleOptions.validate | `validate`} is defined, the command runs against the staged file.
179
+ * Only a zero exit code allows the replacement to proceed.
180
+ *
181
+ * @examples
182
+ * ```yaml
183
+ * - name: Copy a local file to remote, idempotent, set permissions
184
+ * copy:
185
+ * src: ./conf/app.conf
186
+ * dest: /etc/myapp/app.conf
187
+ * mode: "0644"
188
+ * owner: root
189
+ * group: root
190
+ *
191
+ * - name: Render robots.txt
192
+ * copy:
193
+ * content: |
194
+ * User-agent: *
195
+ * Disallow: {{ disallow ? '/' : '' }}
196
+ * dest: /var/www/robots.txt
197
+ *
198
+ * - name: Copy generated file on remote host
199
+ * copy:
200
+ * src: /tmp/generated.cfg
201
+ * dest: /etc/myapp/generated.cfg
202
+ * remote_src: true
203
+ * ```
204
+ *
205
+ * @public
206
+ */
207
+ export class CopyModule extends KatmerModule<
208
+ CopyModuleOptions,
209
+ CopyModuleResult,
210
+ KatmerProvider
211
+ > {
212
+ static name = "copy" as const
213
+
214
+ constraints = {
215
+ platform: {
216
+ any: true
217
+ }
218
+ } satisfies ModuleConstraints
219
+
220
+ /**
221
+ * Validate parameters before execution.
222
+ *
223
+ * @throws Error if:
224
+ * - Both {@link CopyModuleOptions.content | `content`} and {@link CopyModuleOptions.src | `src`} are set.
225
+ * - {@link CopyModuleOptions.dest | `dest`} is missing or empty.
226
+ */
227
+ async check(_ctx: Katmer.TaskContext) {
228
+ if (this.params.content != null && this.params.src != null) {
229
+ throw new Error("copy: 'content' and 'src' are mutually exclusive.")
230
+ }
231
+ if (!this.params.dest) {
232
+ throw new Error("copy: 'dest' is required.")
233
+ }
234
+ }
235
+
236
+ /** Initialize resources (no-op). */
237
+ async initialize(_ctx: Katmer.TaskContext) {}
238
+ /** Cleanup resources (no-op). */
239
+ async cleanup(_ctx: Katmer.TaskContext) {}
240
+
241
+ /**
242
+ * Execute the copy:
243
+ * - Renders string fields (including `content`) against `ctx.variables`.
244
+ * - Branches to SSH or Local implementation.
245
+ * - Returns {@link CopyModuleResult} with `changed`, `checksum`, optional `backup_file`, etc.
246
+ */
247
+ async execute(ctx: Katmer.TaskContext): Promise<CopyModuleResult> {
248
+ const p = await this.renderFields(this.params, ctx)
249
+
250
+ try {
251
+ if (ctx.provider instanceof SSHProvider) {
252
+ return await this.runSsh(ctx as Katmer.TaskContext<SSHProvider>, p)
253
+ } else if (ctx.provider instanceof LocalProvider) {
254
+ return await this.runLocal(ctx, p)
255
+ }
256
+ throw new Error(
257
+ `copy: unsupported provider ${ctx.provider?.constructor?.name}`
258
+ )
259
+ } catch (err: any) {
260
+ return {
261
+ changed: false,
262
+ failed: true,
263
+ msg: err?.message || String(err),
264
+ dest: p.dest
265
+ }
266
+ }
267
+ }
268
+
269
+ // ────────────────────────────────────────────────────────────────────────────────
270
+ // Provider paths
271
+ // ────────────────────────────────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Local provider path:
275
+ * - Ensures parent dir
276
+ * - Stages to a temp file (from `content` or `src`)
277
+ * - Optional validation
278
+ * - Optional backup
279
+ * - Atomic move to `dest`
280
+ * - Apply `mode` and `owner`/`group` via Node APIs
281
+ *
282
+ * @internal
283
+ */
284
+ private async runLocal(
285
+ ctx: Katmer.TaskContext<LocalProvider>,
286
+ p: RequiredSome<CopyModuleOptions, "dest">
287
+ ): Promise<CopyModuleResult> {
288
+ const parents = p.parents ?? true
289
+ const force = !!p.force
290
+ const doBackup = !!p.backup
291
+ const dest = p.dest
292
+
293
+ if (parents) await fs.ensureDir(path.dirname(dest))
294
+
295
+ const existsBefore = await fs.pathExists(dest)
296
+ const prevHash = existsBefore ? await sha256FileLocal(dest) : null
297
+
298
+ let tmpPath: string | null = null
299
+ if (p.content != null) {
300
+ tmpPath = await writeTempLocal(p.content)
301
+ } else if (p.src) {
302
+ tmpPath = await writeTempLocal(await fs.readFile(p.src))
303
+ } else if (p.remote_src) {
304
+ throw new Error("copy: 'remote_src' is only valid with SSH provider.")
305
+ } else {
306
+ throw new Error("copy: one of 'content' or 'src' must be provided.")
307
+ }
308
+
309
+ // Idempotency: compare hashes if dest exists and not forced
310
+ let changed = true
311
+ if (existsBefore && !force) {
312
+ const incomingHash = await sha256FileLocal(tmpPath!)
313
+ if (prevHash && incomingHash && prevHash === incomingHash) {
314
+ changed = false
315
+ }
316
+ }
317
+
318
+ // Validate against temp file
319
+ if (p.validate && tmpPath) {
320
+ await validateLocalViaExec(p.validate, tmpPath)
321
+ }
322
+
323
+ // Backup before replace
324
+ let backup_file: string | undefined
325
+ if (doBackup && existsBefore && changed) {
326
+ backup_file = `${dest}.${timestamp()}.bak`
327
+ await fs.copy(dest, backup_file, {
328
+ preserveTimestamps: true,
329
+ errorOnExist: false
330
+ })
331
+ }
332
+
333
+ // Move staged into place atomically
334
+ if (changed && tmpPath) {
335
+ await fs.move(tmpPath, dest, { overwrite: true })
336
+ tmpPath = null
337
+ } else if (tmpPath) {
338
+ await fs.remove(tmpPath)
339
+ tmpPath = null
340
+ }
341
+
342
+ // Apply mode (Node API)
343
+ if (process.platform === "win32") {
344
+ // skip chmod on Windows
345
+ } else if (p.mode != null) {
346
+ try {
347
+ await fs.chmod(dest, parseMode(p.mode))
348
+ } catch (e) {
349
+ ctx.logger?.warn?.({
350
+ msg: `copy(local): chmod failed: ${String(e)}`
351
+ })
352
+ }
353
+ }
354
+
355
+ // Apply owner/group (POSIX)
356
+ if (process.platform === "win32") {
357
+ // skip on Windows
358
+ } else if (p.owner != null || p.group != null) {
359
+ try {
360
+ const ids = await toUidGid(p.owner, p.group, ctx)
361
+ if (ids) {
362
+ await fs.chown(
363
+ dest,
364
+ ids.uid ?? (await currentUid()),
365
+ ids.gid ?? (await currentGid())
366
+ )
367
+ } else {
368
+ ctx.logger?.warn?.({
369
+ msg: "copy(local): could not resolve owner/group; skipping chown"
370
+ })
371
+ }
372
+ } catch (e) {
373
+ // On Windows or unsupported FS, chown may throw — log and continue
374
+ ctx.logger?.warn?.({
375
+ msg: `copy(local): chown failed: ${String(e)}`
376
+ })
377
+ }
378
+ }
379
+
380
+ const finalHash =
381
+ (await fs.pathExists(dest)) ? await sha256FileLocal(dest) : null
382
+
383
+ return {
384
+ changed,
385
+ failed: false,
386
+ dest,
387
+ checksum: finalHash ?? undefined,
388
+ backup_file,
389
+ validated: !!p.validate
390
+ }
391
+ }
392
+
393
+ /**
394
+ * SSH provider path:
395
+ * - Ensures parent dir
396
+ * - Stages to a remote temp file (upload or remote `cp`)
397
+ * - Idempotency by remote hash
398
+ * - Optional validation on staged temp file
399
+ * - Optional backup
400
+ * - Atomic `mv` into place
401
+ * - Apply `mode` / `owner` / `group` via shell
402
+ *
403
+ * @internal
404
+ */
405
+ private async runSsh(
406
+ ctx: Katmer.TaskContext<SSHProvider>,
407
+ p: RequiredSome<CopyModuleOptions, "dest">
408
+ ): Promise<CopyModuleResult> {
409
+ const osfam = ctx.provider.os.family as string
410
+
411
+ const parents = p.parents ?? true
412
+ const force = !!p.force
413
+ const doBackup = !!p.backup
414
+ const dest = p.dest
415
+ const client = ctx.provider.client!
416
+
417
+ // Ensure parent of dest exists (runs via shell; your become wrapper applies here)
418
+ if (parents) {
419
+ if (osfam === "windows") {
420
+ await WindowsComms.ensureDir(
421
+ ctx,
422
+ dest.replace(/\\/g, "/").replace(/\/[^/]*$/, "")
423
+ )
424
+ } else {
425
+ await ctx.exec(`mkdir -p -- $(dirname -- ${JSON.stringify(dest)})`)
426
+ }
427
+ }
428
+
429
+ // Existing dest hash (if any)
430
+ const existed = await UnixComms.fileExists(ctx, dest)
431
+ const prevHash = existed ? await sha256FileRemote(ctx, dest) : null
432
+
433
+ // Always stage in a user-writable directory to avoid SFTP permission problems
434
+ const remoteTmp = path.posix.join(
435
+ REMOTE_STAGE_DIR,
436
+ `katmer-copy-${randomId()}`
437
+ )
438
+ let staged = false
439
+
440
+ // ---- Stage content/file into /tmp (SFTP for local->remote; cp for remote_src) ----
441
+ if (p.content != null) {
442
+ const tmp = await writeTempLocal(p.content)
443
+ try {
444
+ await client.putFile(tmp, remoteTmp) // SFTP never needs sudo; /tmp is writable
445
+ staged = true
446
+ } finally {
447
+ await fs.remove(tmp)
448
+ }
449
+ } else if (p.src && !p.remote_src) {
450
+ if (osfam === "windows") {
451
+ // PutFile works too, but ensure Windows path separators are handled by node-ssh
452
+ await client.putFile(p.src, remoteTmp)
453
+ } else {
454
+ await client.putFile(p.src, remoteTmp)
455
+ } // controller -> remote:/tmp
456
+ staged = true
457
+ } else if (p.src && p.remote_src) {
458
+ // Remote -> /tmp (shell; become applies as needed for reading src)
459
+ await ctx.exec(
460
+ `cp -f -- ${JSON.stringify(p.src)} ${JSON.stringify(remoteTmp)}`
461
+ )
462
+ staged = true
463
+ } else {
464
+ throw new Error("copy: one of 'content' or 'src' must be provided.")
465
+ }
466
+
467
+ // ---- Idempotency (if not forced) compare remote hashes ----
468
+ let changed = true
469
+ if (existed && staged && !force) {
470
+ const incomingHash = await sha256FileRemote(ctx, remoteTmp)
471
+ if (prevHash && incomingHash && prevHash === incomingHash) {
472
+ await ctx.exec(`rm -f -- ${JSON.stringify(remoteTmp)}`)
473
+ staged = false
474
+ changed = false
475
+ }
476
+ }
477
+
478
+ // ---- Validate staged file (runs via shell; become applies) ----
479
+ if (staged && p.validate) {
480
+ await validateRemoteViaShell(ctx, p.validate, remoteTmp)
481
+ }
482
+
483
+ // ---- Optional backup of current dest ----
484
+ let backup_file: string | undefined
485
+ if (doBackup && existed && changed) {
486
+ backup_file = `${dest}.${timestamp()}.bak`
487
+ await ctx.exec(
488
+ `cp -p -- ${JSON.stringify(dest)} ${JSON.stringify(backup_file)} `
489
+ )
490
+ }
491
+
492
+ // ---- Install into place (atomic as possible) ----
493
+ if (staged && changed) {
494
+ const modeStr = p.mode != null ? toOctal(p.mode) : undefined
495
+ if (modeStr) {
496
+ // Try install first (creates parents, sets mode atomically); fallback to mv+chmod
497
+ await ctx.exec(
498
+ `install -D -m ${modeStr} -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)} || (mv -f -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)} && chmod ${modeStr} -- ${JSON.stringify(dest)})`
499
+ )
500
+ } else {
501
+ await ctx.exec(
502
+ `mv -f -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)}`
503
+ )
504
+ }
505
+ } else if (staged) {
506
+ await ctx.exec(`rm -f -- ${JSON.stringify(remoteTmp)}`)
507
+ }
508
+
509
+ // ---- Owner / group (after install), still via shell (become applies) ----
510
+ if (osfam === "windows") {
511
+ // Skip POSIX owner/group/mode. (Future: use icacls to set ACLs if provided.)
512
+ } else {
513
+ if (p.owner || p.group) {
514
+ const who = [p.owner ?? "", p.group ? `:${p.group}` : ""].join("")
515
+ await ctx.exec(`chown ${who} -- ${JSON.stringify(dest)}`)
516
+ }
517
+ if (p.mode != null) {
518
+ await ctx.exec(`chmod ${toOctal(p.mode)} -- ${JSON.stringify(dest)}`)
519
+ }
520
+ }
521
+
522
+ // Final hash (best-effort)
523
+ const finalHash =
524
+ osfam === "windows" ? null
525
+ : (await UnixComms.fileExists(ctx, dest)) ?
526
+ await sha256FileRemote(ctx, dest)
527
+ : null
528
+
529
+ return {
530
+ changed,
531
+ failed: false,
532
+ dest,
533
+ checksum: finalHash ?? undefined,
534
+ backup_file,
535
+ validated: !!p.validate
536
+ }
537
+ }
538
+
539
+ // ────────────────────────────────────────────────────────────────────────────────
540
+ // Helpers
541
+ // ────────────────────────────────────────────────────────────────────────────────
542
+
543
+ /**
544
+ * Render only the string-like fields against `ctx.variables`.
545
+ * - `content` is rendered only if it is a `string`; `Uint8Array` is passed through untouched.
546
+ *
547
+ * @internal
548
+ */
549
+ private async renderFields(
550
+ input: CopyModuleOptions,
551
+ ctx: Katmer.TaskContext<KatmerProvider>
552
+ ): Promise<RequiredSome<CopyModuleOptions, "dest">> {
553
+ const out: CopyModuleOptions = { ...input }
554
+ const renderIfString = async <T extends string | number | undefined>(
555
+ v?: T
556
+ ): Promise<T> =>
557
+ typeof v === "string" ?
558
+ ((await evalTemplate(v, ctx.variables)) as T)
559
+ : (v as T)
560
+
561
+ const absFromCwd = (s?: string) => {
562
+ if (typeof s !== "string" || !s.trim()) return s
563
+ // If already absolute, keep it. Else resolve from config.cwd (fallback to process.cwd()).
564
+ return path.isAbsolute(s) ? s : (
565
+ path.resolve(ctx.config?.cwd ?? process.cwd(), s)
566
+ )
567
+ }
568
+
569
+ out.src = absFromCwd(await renderIfString(input.src))
570
+ out.dest = await renderIfString(input.dest)
571
+ out.owner = await renderIfString(input.owner)
572
+ out.group = await renderIfString(input.group)
573
+ out.mode = await renderIfString(input.mode)
574
+
575
+ if (typeof input.content === "string") {
576
+ out.content = await renderIfString(input.content)
577
+ } else {
578
+ out.content = input.content
579
+ }
580
+
581
+ // Helpful early failure for local→remote branch
582
+ if (!out.content && out.src && !(await fs.pathExists(out.src))) {
583
+ throw new Error(
584
+ `copy: local source not found: ${out.src} (resolved from ${ctx.config?.cwd ?? process.cwd()})`
585
+ )
586
+ }
587
+
588
+ if (!out.dest) throw new Error("copy: rendered 'dest' was empty.")
589
+ return out as RequiredSome<CopyModuleOptions, "dest">
590
+ }
591
+ }
592
+
593
+ // ────────────────────────────────────────────────────────────────────────────────
594
+ // Utility functions (shared)
595
+ // ────────────────────────────────────────────────────────────────────────────────
596
+
597
+ /** @internal */
598
+ type RequiredSome<T, K extends keyof T> = T & Required<Pick<T, K>>
599
+
600
+ /** @internal */
601
+ function randomId() {
602
+ return crypto.randomBytes(5).toString("hex")
603
+ }
604
+ /** @internal */
605
+ function timestamp() {
606
+ const d = new Date()
607
+ const pad = (n: number) => String(n).padStart(2, "0")
608
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
609
+ }
610
+
611
+ /** @internal */
612
+ async function writeTempLocal(content: string | Uint8Array): Promise<string> {
613
+ const tmp = path.join(os.tmpdir(), `katmer-copy-${randomId()}`)
614
+ await fs.writeFile(tmp, content)
615
+ return tmp
616
+ }
617
+
618
+ /** @internal */
619
+ async function sha256FileLocal(p: string): Promise<string | null> {
620
+ try {
621
+ const hash = crypto.createHash("sha256")
622
+ const s = fs.createReadStream(p)
623
+ await new Promise<void>((resolve, reject) => {
624
+ s.on("data", (c) => hash.update(c))
625
+ s.on("error", reject)
626
+ s.on("end", () => resolve())
627
+ })
628
+ return hash.digest("hex")
629
+ } catch {
630
+ return null
631
+ }
632
+ }
633
+
634
+ /** @internal */
635
+ function parseMode(mode: string | number): number {
636
+ if (typeof mode === "number") return mode
637
+ const m = String(mode).trim()
638
+ // accept "0644" or "644"
639
+ const oct = m.startsWith("0") ? m : "0" + m
640
+ return Number.parseInt(oct, 8)
641
+ }
642
+
643
+ /** @internal */
644
+ async function sha256FileRemote(
645
+ ctx: Katmer.TaskContext<SSHProvider>,
646
+ p: string
647
+ ): Promise<string | null> {
648
+ let r = await ctx.execSafe(
649
+ `sha256sum -- ${JSON.stringify(p)} 2>/dev/null || true`
650
+ )
651
+ if (r.code === 0 && r.stdout?.trim()) {
652
+ const m = r.stdout.trim().match(/^([a-f0-9]{64})\s+/i)
653
+ if (m) return m[1].toLowerCase()
654
+ }
655
+ r = await ctx.execSafe(
656
+ `shasum -a 256 -- ${JSON.stringify(p)} 2>/dev/null || true`
657
+ )
658
+ if (r.code === 0 && r.stdout?.trim()) {
659
+ const m = r.stdout.trim().match(/^([a-f0-9]{64})\s+/i)
660
+ if (m) return m[1].toLowerCase()
661
+ }
662
+ return null
663
+ }
664
+
665
+ /** @internal */
666
+ async function validateRemoteViaShell(
667
+ ctx: Katmer.TaskContext<SSHProvider>,
668
+ template: string,
669
+ tmpPath: string
670
+ ) {
671
+ const quoted = JSON.stringify(tmpPath)
672
+ const cmd =
673
+ template.includes("%s") ?
674
+ template.replaceAll("%s", quoted)
675
+ : `${template} ${quoted}`
676
+ const r = await ctx.execSafe(cmd)
677
+ if (r.code) {
678
+ const details = (r.stderr || r.stdout || "").trim()
679
+ throw new Error(
680
+ details ?
681
+ `validate failed: ${details}`
682
+ : `validate failed with code ${r.code}`
683
+ )
684
+ }
685
+ }
686
+
687
+ /** @internal */
688
+ async function validateLocalViaExec(template: string, tmpPath: string) {
689
+ const cmd =
690
+ template.includes("%s") ?
691
+ template.replaceAll("%s", tmpPath)
692
+ : `${template} ${tmpPath}`
693
+ // Use a shell because template may contain flags/pipe/redir;
694
+ const { stderr } = await exec(cmd).catch((e: any) => {
695
+ const msg = e?.stderr || e?.stdout || e?.message || String(e)
696
+ const err = new Error(`validate failed: ${msg.trim()}`)
697
+ ;(err as any).code = e?.code
698
+ throw err
699
+ })
700
+ if (stderr && /error/i.test(stderr)) {
701
+ // not fatal by itself; command exit code already handled
702
+ }
703
+ }
704
+
705
+ // POSIX uid/gid resolution via /etc files
706
+
707
+ /** @internal */
708
+ const passwdCache = new Map<string, number>()
709
+ /** @internal */
710
+ const groupCache = new Map<string, number>()
711
+ /** @internal */
712
+ let passwdLoaded = false
713
+ /** @internal */
714
+ let groupLoaded = false
715
+
716
+ /** @internal */
717
+ async function loadEtcPasswd() {
718
+ if (passwdLoaded || process.platform === "win32") return
719
+ try {
720
+ const data = await fs.readFile("/etc/passwd", "utf8")
721
+ for (const line of data.split(/\r?\n/)) {
722
+ if (!line || line.startsWith("#")) continue
723
+ const [name, , uidStr] = line.split(":")
724
+ const uid = Number.parseInt(uidStr, 10)
725
+ if (!Number.isNaN(uid)) passwdCache.set(name, uid)
726
+ }
727
+ } catch {}
728
+ passwdLoaded = true
729
+ }
730
+
731
+ /** @internal */
732
+ async function loadEtcGroup() {
733
+ if (groupLoaded || process.platform === "win32") return
734
+ try {
735
+ const data = await fs.readFile("/etc/group", "utf8")
736
+ for (const line of data.split(/\r?\n/)) {
737
+ if (!line || line.startsWith("#")) continue
738
+ const [name, , gidStr] = line.split(":")
739
+ const gid = Number.parseInt(gidStr, 10)
740
+ if (!Number.isNaN(gid)) groupCache.set(name, gid)
741
+ }
742
+ } catch {}
743
+ groupLoaded = true
744
+ }
745
+
746
+ /** @internal */
747
+ async function currentUid(): Promise<number> {
748
+ try {
749
+ return process.getuid?.() ?? 0
750
+ } catch {
751
+ return 0
752
+ }
753
+ }
754
+ /** @internal */
755
+ async function currentGid(): Promise<number> {
756
+ try {
757
+ return process.getgid?.() ?? 0
758
+ } catch {
759
+ return 0
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Resolve `owner`/`group` to POSIX `uid`/`gid` on local systems.
765
+ * - Accepts either numbers or names; names are resolved using `/etc/passwd` and `/etc/group`.
766
+ * - Returns `null` on Windows or when neither could be resolved.
767
+ *
768
+ * @internal
769
+ */
770
+ async function toUidGid(
771
+ owner?: string,
772
+ group?: string,
773
+ ctx?: { logger?: any }
774
+ ): Promise<{ uid?: number; gid?: number } | null> {
775
+ if (process.platform === "win32") {
776
+ // Windows does not support POSIX chown meaningfully.
777
+ return null
778
+ }
779
+
780
+ await loadEtcPasswd()
781
+ await loadEtcGroup()
782
+
783
+ const out: { uid?: number; gid?: number } = {}
784
+
785
+ if (owner != null) {
786
+ const maybeNum = Number(owner)
787
+ if (Number.isInteger(maybeNum)) out.uid = maybeNum
788
+ else if (passwdCache.has(owner)) out.uid = passwdCache.get(owner)!
789
+ else
790
+ ctx?.logger?.warn?.({
791
+ msg: `copy(local): unknown owner '${owner}', skipping uid change`
792
+ })
793
+ }
794
+
795
+ if (group != null) {
796
+ const maybeNum = Number(group)
797
+ if (Number.isInteger(maybeNum)) out.gid = maybeNum
798
+ else if (groupCache.has(group)) out.gid = groupCache.get(group)!
799
+ else
800
+ ctx?.logger?.warn?.({
801
+ msg: `copy(local): unknown group '${group}', skipping gid change`
802
+ })
803
+ }
804
+
805
+ if (out.uid == null && out.gid == null) return null
806
+ return out
807
+ }