@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,478 @@
|
|
|
1
|
+
// packages/core/modules/template.module.ts
|
|
2
|
+
import {
|
|
3
|
+
type ModuleCommonReturn,
|
|
4
|
+
type ModuleConstraints
|
|
5
|
+
} from "../interfaces/module.interface"
|
|
6
|
+
import type { Katmer } from "../interfaces/task.interface"
|
|
7
|
+
import type { KatmerProvider } from "../interfaces/provider.interface"
|
|
8
|
+
import { SSHProvider } from "../providers/ssh/ssh.provider"
|
|
9
|
+
import type { LocalProvider } from "../providers/local.provider"
|
|
10
|
+
import { evalTemplate } from "../utils/renderer/renderer"
|
|
11
|
+
import { toMerged } from "es-toolkit"
|
|
12
|
+
import fs from "node:fs/promises"
|
|
13
|
+
import path from "node:path"
|
|
14
|
+
import { toOctal } from "../utils/number.utils"
|
|
15
|
+
import { KatmerModule } from "../module"
|
|
16
|
+
|
|
17
|
+
declare module "../interfaces/task.interface" {
|
|
18
|
+
export namespace Katmer {
|
|
19
|
+
export interface TaskActions {
|
|
20
|
+
template?: TemplateModuleOptions
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Options for the `template` module.
|
|
26
|
+
*
|
|
27
|
+
* Renders a Twig template (from inline `content` or a file `src`) **locally** using
|
|
28
|
+
* the merged data context and writes the rendered result to `dest`.
|
|
29
|
+
* - On **SSH** providers, writes to the remote via shell, preserving atomicity with a temp file.
|
|
30
|
+
* - On **Local** provider, writes directly using Node.js `fs` APIs (no shell).
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
export interface TemplateModuleOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Inline template string (Twig syntax supported).
|
|
37
|
+
* One of {@link TemplateModuleOptions.content | content} or {@link TemplateModuleOptions.src | src} is required.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```yaml
|
|
41
|
+
* template:
|
|
42
|
+
* content: |
|
|
43
|
+
* server_name {{ domain }};
|
|
44
|
+
* listen {{ port }};
|
|
45
|
+
* dest: "/etc/myapp/site.conf"
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
content?: string
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Local file path to read the template from (Twig syntax supported).
|
|
52
|
+
* One of {@link TemplateModuleOptions.src | src} or {@link TemplateModuleOptions.content | content} is required.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```yaml
|
|
56
|
+
* template:
|
|
57
|
+
* src: "./_source/site.conf.twig"
|
|
58
|
+
* dest: "/etc/myapp/site.conf"
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
src?: string
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Destination path to write the rendered output into.
|
|
65
|
+
* - **SSH**: remote absolute path on the target host.
|
|
66
|
+
* - **Local**: local filesystem path.
|
|
67
|
+
*/
|
|
68
|
+
dest: string
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extra variables merged **on top of** `ctx.variables` for this render only.
|
|
72
|
+
* Task/target variables remain intact; these act as per-render overrides.
|
|
73
|
+
*/
|
|
74
|
+
variables?: Record<string, any>
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* File permissions to apply on `dest`.
|
|
78
|
+
* Accepts octal number (e.g., `0o644`) or string (e.g., `"0644"`).
|
|
79
|
+
* @defaultValue `"0644"`
|
|
80
|
+
*/
|
|
81
|
+
mode?: string | number
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* File owner to apply on `dest`.
|
|
85
|
+
* - **SSH**: accepts a username (e.g., `"root"`).
|
|
86
|
+
* - **Local**: must be **numeric uid**; otherwise the chown attempt is skipped and a warning is logged.
|
|
87
|
+
*/
|
|
88
|
+
owner?: string
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* File group to apply on `dest`.
|
|
92
|
+
* - **SSH**: accepts a group name (e.g., `"www-data"`).
|
|
93
|
+
* - **Local**: must be **numeric gid**; otherwise the chown attempt is skipped and a warning is logged.
|
|
94
|
+
*/
|
|
95
|
+
group?: string
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* If `true`, do not write anything; returns the rendered content in {@link TemplateModuleResult.stdout | stdout}.
|
|
99
|
+
* @defaultValue false
|
|
100
|
+
*/
|
|
101
|
+
dry_run?: boolean
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* If `true`, compares the existing file content with the newly rendered result.
|
|
105
|
+
* When identical, the module returns `changed=false` without writing.
|
|
106
|
+
* @defaultValue true
|
|
107
|
+
*/
|
|
108
|
+
diff_check?: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TemplateModuleResult extends ModuleCommonReturn {
|
|
112
|
+
/** Destination path that was written (or examined). */
|
|
113
|
+
dest?: string
|
|
114
|
+
/** File mode applied to `dest` (octal string, e.g. `"0644"`). */
|
|
115
|
+
mode?: string
|
|
116
|
+
/** File owner applied to `dest`. */
|
|
117
|
+
owner?: string
|
|
118
|
+
/** File group applied to `dest`. */
|
|
119
|
+
group?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render a Twig template locally and deploy the result to a file (remote for SSH, local for Local).
|
|
124
|
+
*
|
|
125
|
+
* @remarks
|
|
126
|
+
* - Renders with the merged context of `ctx.variables` + per-call `variables`.
|
|
127
|
+
* - If {@link TemplateModuleOptions.diff_check | diff_check} is `true` (default), content is compared to avoid needless writes.
|
|
128
|
+
* - On **SSH**:
|
|
129
|
+
* - Ensures parent directory via shell (`mkdir -p`).
|
|
130
|
+
* - Writes through a temporary file and installs atomically (`install -m … -D` or `mv` fallback).
|
|
131
|
+
* - Applies `chmod`/`chown` when requested.
|
|
132
|
+
* - On **Local**:
|
|
133
|
+
* - Uses pure Node.js (`fs.mkdir`, `fs.readFile`, `fs.writeFile`, `fs.rename`, `fs.chmod`, `fs.chown`).
|
|
134
|
+
* - `owner`/`group` must be numeric (`uid`/`gid`).
|
|
135
|
+
*
|
|
136
|
+
* @examples
|
|
137
|
+
* ```yaml
|
|
138
|
+
* - name: Render inline text template
|
|
139
|
+
* template:
|
|
140
|
+
* content: |
|
|
141
|
+
* server_name {{ domain }};
|
|
142
|
+
* listen {{ port }};
|
|
143
|
+
* dest: "/etc/myapp/config.conf"
|
|
144
|
+
* mode: "0644"
|
|
145
|
+
* owner: "root"
|
|
146
|
+
* group: "root"
|
|
147
|
+
* variables:
|
|
148
|
+
* domain: "example.com"
|
|
149
|
+
* port: 8080
|
|
150
|
+
*
|
|
151
|
+
* - name: Read template from local file
|
|
152
|
+
* template:
|
|
153
|
+
* src: "./_source/metadata.yaml.twig"
|
|
154
|
+
* dest: "/etc/myapp/metadata.yaml"
|
|
155
|
+
* variables:
|
|
156
|
+
* app: "demo"
|
|
157
|
+
*
|
|
158
|
+
* - name: Dry run (only preview rendered content)
|
|
159
|
+
* template:
|
|
160
|
+
* src: "./_source/site.conf.twig"
|
|
161
|
+
* dest: "/etc/nginx/sites-available/site.conf"
|
|
162
|
+
* dry_run: true
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
export class TemplateModule extends KatmerModule<
|
|
168
|
+
TemplateModuleOptions,
|
|
169
|
+
TemplateModuleResult,
|
|
170
|
+
KatmerProvider
|
|
171
|
+
> {
|
|
172
|
+
static name = "template" as const
|
|
173
|
+
|
|
174
|
+
constraints = {
|
|
175
|
+
platform: {
|
|
176
|
+
any: true
|
|
177
|
+
}
|
|
178
|
+
} satisfies ModuleConstraints
|
|
179
|
+
|
|
180
|
+
async check(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {
|
|
181
|
+
const { content, src, dest } = this.params || ({} as TemplateModuleOptions)
|
|
182
|
+
if (!dest || typeof dest !== "string" || !dest.trim()) {
|
|
183
|
+
throw new Error("'dest' is required")
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
(content == null || typeof content !== "string") &&
|
|
187
|
+
(src == null || typeof src !== "string")
|
|
188
|
+
) {
|
|
189
|
+
throw new Error("one of 'content' or 'src' must be provided")
|
|
190
|
+
}
|
|
191
|
+
if (src != null && typeof src !== "string") {
|
|
192
|
+
throw new Error("'src' must be a string path")
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async initialize(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
|
|
197
|
+
async cleanup(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
|
|
198
|
+
|
|
199
|
+
async execute(
|
|
200
|
+
ctx: Katmer.TaskContext<KatmerProvider>
|
|
201
|
+
): Promise<TemplateModuleResult> {
|
|
202
|
+
const {
|
|
203
|
+
content,
|
|
204
|
+
src,
|
|
205
|
+
dest,
|
|
206
|
+
variables = {},
|
|
207
|
+
mode = "0644",
|
|
208
|
+
owner,
|
|
209
|
+
group,
|
|
210
|
+
dry_run = false,
|
|
211
|
+
diff_check = true
|
|
212
|
+
} = this.params
|
|
213
|
+
|
|
214
|
+
// Resolve template source (src or content)
|
|
215
|
+
let templateString: string
|
|
216
|
+
try {
|
|
217
|
+
if (typeof src === "string" && src.trim()) {
|
|
218
|
+
const abs = path.resolve(...[ctx.config.cwd!, src].filter(Boolean))
|
|
219
|
+
templateString = await fs.readFile(abs, "utf8")
|
|
220
|
+
} else {
|
|
221
|
+
templateString = String(content ?? "")
|
|
222
|
+
}
|
|
223
|
+
} catch (e: any) {
|
|
224
|
+
throw {
|
|
225
|
+
changed: false,
|
|
226
|
+
msg: `failed to read src: ${e?.message || String(e)}`
|
|
227
|
+
} as TemplateModuleResult
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Render locally using Twig renderer
|
|
231
|
+
let rendered: string
|
|
232
|
+
try {
|
|
233
|
+
rendered = await evalTemplate(
|
|
234
|
+
templateString,
|
|
235
|
+
toMerged(ctx.variables, variables)
|
|
236
|
+
)
|
|
237
|
+
} catch (e: any) {
|
|
238
|
+
throw {
|
|
239
|
+
changed: false,
|
|
240
|
+
msg: `template render failed: ${e?.message || String(e)}`
|
|
241
|
+
} as TemplateModuleResult
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Dry-run — just return the would-be content
|
|
245
|
+
if (dry_run) {
|
|
246
|
+
return {
|
|
247
|
+
changed: false,
|
|
248
|
+
dest,
|
|
249
|
+
mode: toModeString(mode),
|
|
250
|
+
owner,
|
|
251
|
+
group,
|
|
252
|
+
stdout: rendered
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Branch by provider type
|
|
257
|
+
if (ctx.provider instanceof SSHProvider) {
|
|
258
|
+
return await this.executeSSH(ctx as Katmer.TaskContext<SSHProvider>, {
|
|
259
|
+
rendered,
|
|
260
|
+
dest,
|
|
261
|
+
mode,
|
|
262
|
+
owner,
|
|
263
|
+
group,
|
|
264
|
+
diff_check
|
|
265
|
+
})
|
|
266
|
+
} else {
|
|
267
|
+
return await this.executeLocal(ctx as Katmer.TaskContext<LocalProvider>, {
|
|
268
|
+
rendered,
|
|
269
|
+
dest,
|
|
270
|
+
mode,
|
|
271
|
+
owner,
|
|
272
|
+
group,
|
|
273
|
+
diff_check
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── SSH path (shell-based; mirrors your previous behavior) ───────────────────
|
|
279
|
+
private async executeSSH(
|
|
280
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
281
|
+
opts: {
|
|
282
|
+
rendered: string
|
|
283
|
+
dest: string
|
|
284
|
+
mode?: string | number
|
|
285
|
+
owner?: string
|
|
286
|
+
group?: string
|
|
287
|
+
diff_check: boolean
|
|
288
|
+
}
|
|
289
|
+
): Promise<TemplateModuleResult> {
|
|
290
|
+
const { rendered, dest, mode, owner, group, diff_check } = opts
|
|
291
|
+
const q = (v: string) => JSON.stringify(v)
|
|
292
|
+
|
|
293
|
+
// Ensure parent directory
|
|
294
|
+
const mk = await ctx.exec(`mkdir -p -- "$(dirname ${q(dest)})"`)
|
|
295
|
+
if (mk.code !== 0) {
|
|
296
|
+
throw {
|
|
297
|
+
changed: false,
|
|
298
|
+
msg: mk.stderr || mk.stdout || "failed to ensure destination directory"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fail if dest is a directory
|
|
303
|
+
const isDir = await ctx.exec(`test -d ${q(dest)} >/dev/null 2>&1; echo $?`)
|
|
304
|
+
if (String(isDir.stdout).trim() === "0") {
|
|
305
|
+
throw {
|
|
306
|
+
changed: false,
|
|
307
|
+
msg: `'${dest}' is a directory`
|
|
308
|
+
} as TemplateModuleResult
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Diff check
|
|
312
|
+
if (diff_check) {
|
|
313
|
+
const exists = await ctx.exec(
|
|
314
|
+
`test -f ${q(dest)} >/dev/null 2>&1; echo $?`
|
|
315
|
+
)
|
|
316
|
+
if (String(exists.stdout).trim() === "0") {
|
|
317
|
+
const read = await ctx.exec(`cat ${q(dest)}`)
|
|
318
|
+
if (read.code === 0 && read.stdout === rendered) {
|
|
319
|
+
await applyMetaSSH(ctx, dest, mode, owner, group).catch(() => {})
|
|
320
|
+
return {
|
|
321
|
+
changed: false,
|
|
322
|
+
dest,
|
|
323
|
+
mode: toModeString(mode),
|
|
324
|
+
owner,
|
|
325
|
+
group
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Write via temp + install
|
|
332
|
+
const tmp = `/tmp/katmer-template-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
|
|
333
|
+
const writeTmp = await ctx.exec(`cat > ${q(tmp)} << "KATMER_EOF"
|
|
334
|
+
${rendered}
|
|
335
|
+
KATMER_EOF`)
|
|
336
|
+
if (writeTmp.code !== 0) {
|
|
337
|
+
await ctx.exec(`rm -f -- ${q(tmp)}`).catch(() => {})
|
|
338
|
+
throw {
|
|
339
|
+
changed: false,
|
|
340
|
+
msg: writeTmp.stderr || writeTmp.stdout || "failed to write temp file"
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const modeStr = toModeString(mode) ?? "0644"
|
|
345
|
+
const installCmd = `install -m ${modeStr} -D ${q(tmp)} ${q(dest)} || mv -f ${q(tmp)} ${q(dest)}`
|
|
346
|
+
const mv = await ctx.exec(installCmd)
|
|
347
|
+
await ctx.exec(`rm -f -- ${q(tmp)}`).catch(() => {})
|
|
348
|
+
if (mv.code !== 0) {
|
|
349
|
+
throw {
|
|
350
|
+
changed: false,
|
|
351
|
+
msg: mv.stderr || mv.stdout || "failed to install rendered file"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await applyMetaSSH(ctx, dest, mode, owner, group).catch(() => {})
|
|
356
|
+
|
|
357
|
+
return { changed: true, dest, mode: toModeString(mode), owner, group }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Local path (pure Node.js; no shell) ──────────────────────────────────────
|
|
361
|
+
private async executeLocal(
|
|
362
|
+
ctx: Katmer.TaskContext<LocalProvider>,
|
|
363
|
+
opts: {
|
|
364
|
+
rendered: string
|
|
365
|
+
dest: string
|
|
366
|
+
mode?: string | number
|
|
367
|
+
owner?: string
|
|
368
|
+
group?: string
|
|
369
|
+
diff_check: boolean
|
|
370
|
+
}
|
|
371
|
+
): Promise<TemplateModuleResult> {
|
|
372
|
+
const { rendered, dest, owner, group, diff_check } = opts
|
|
373
|
+
const mode = toOctal(opts.mode)
|
|
374
|
+
const parent = path.dirname(dest)
|
|
375
|
+
await fs.mkdir(parent, { recursive: true })
|
|
376
|
+
|
|
377
|
+
// Guard: dest is not a directory
|
|
378
|
+
try {
|
|
379
|
+
const st = await fs.stat(dest)
|
|
380
|
+
if (st.isDirectory()) {
|
|
381
|
+
throw {
|
|
382
|
+
changed: false,
|
|
383
|
+
msg: `'${dest}' is a directory`
|
|
384
|
+
} as TemplateModuleResult
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
/* not existing is fine */
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Diff check
|
|
391
|
+
if (diff_check) {
|
|
392
|
+
try {
|
|
393
|
+
const current = await fs.readFile(dest, "utf8")
|
|
394
|
+
if (current === rendered) {
|
|
395
|
+
await applyMetaLocal(dest, mode, owner, group, ctx).catch(() => {})
|
|
396
|
+
return {
|
|
397
|
+
changed: false,
|
|
398
|
+
dest,
|
|
399
|
+
mode: toModeString(mode),
|
|
400
|
+
owner,
|
|
401
|
+
group
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
/* file missing → proceed */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Atomic-ish write: tmp → rename
|
|
410
|
+
const tmp = path.join(
|
|
411
|
+
parent,
|
|
412
|
+
`.katmer-template-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
|
|
413
|
+
)
|
|
414
|
+
await fs.writeFile(tmp, rendered, "utf8")
|
|
415
|
+
await fs.rename(tmp, dest)
|
|
416
|
+
|
|
417
|
+
await applyMetaLocal(dest, mode, owner, group, ctx).catch(() => {})
|
|
418
|
+
|
|
419
|
+
return { changed: true, dest, mode: toModeString(mode), owner, group }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
424
|
+
function toModeString(mode?: string | number) {
|
|
425
|
+
if (mode == null) return undefined
|
|
426
|
+
return typeof mode === "number" ? "0" + mode.toString(8) : mode
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function applyMetaSSH(
|
|
430
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
431
|
+
dest: string,
|
|
432
|
+
mode?: string | number,
|
|
433
|
+
owner?: string,
|
|
434
|
+
group?: string
|
|
435
|
+
) {
|
|
436
|
+
const q = (v: string) => JSON.stringify(v)
|
|
437
|
+
if (mode != null) {
|
|
438
|
+
await ctx.exec(`chmod ${mode} -- ${q(dest)}`)
|
|
439
|
+
}
|
|
440
|
+
if (owner || group) {
|
|
441
|
+
const chownArg =
|
|
442
|
+
owner && group ? `${owner}:${group}`
|
|
443
|
+
: owner ? owner
|
|
444
|
+
: `:${group}`
|
|
445
|
+
await ctx.exec(`chown ${q(chownArg)} -- ${q(dest)}`)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function applyMetaLocal(
|
|
450
|
+
dest: string,
|
|
451
|
+
mode?: string,
|
|
452
|
+
owner?: string,
|
|
453
|
+
group?: string,
|
|
454
|
+
ctx?: Katmer.TaskContext<LocalProvider>
|
|
455
|
+
) {
|
|
456
|
+
if (mode != null) {
|
|
457
|
+
await fs.chmod(dest, parseInt(mode, 8))
|
|
458
|
+
}
|
|
459
|
+
if (owner != null || group != null) {
|
|
460
|
+
const st = await fs.stat(dest)
|
|
461
|
+
const uid = parseNumericId(owner) ?? st.uid
|
|
462
|
+
const gid = parseNumericId(group) ?? st.gid
|
|
463
|
+
if (uid !== st.uid || gid !== st.gid) {
|
|
464
|
+
try {
|
|
465
|
+
await fs.chown(dest, uid, gid)
|
|
466
|
+
} catch (e) {
|
|
467
|
+
ctx?.warn?.({
|
|
468
|
+
message: `local chown failed; owner/group must be numeric uid/gid. ${String(e)}`
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseNumericId(v?: string): number | undefined {
|
|
476
|
+
if (!v) return undefined
|
|
477
|
+
return /^\d+$/.test(v) ? Number(v) : undefined
|
|
478
|
+
}
|