@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,605 @@
1
+ // src/modules/gather/fastfetch_facts.module.ts
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+ import fs from "fs-extra"
5
+ import crypto from "node:crypto"
6
+ import AdmZip from "adm-zip"
7
+ import {
8
+ type ModuleCommonReturn,
9
+ type ModuleConstraints
10
+ } from "../interfaces/module.interface"
11
+ import type { Katmer } from "../katmer"
12
+ import type { KatmerProvider } from "../interfaces/provider.interface"
13
+ import { SSHProvider } from "../providers/ssh/ssh.provider"
14
+ import { LocalProvider } from "../providers/local.provider"
15
+ import { KatmerModule } from "../module"
16
+
17
+ type OsKey = "linux" | "darwin" | "windows"
18
+
19
+ declare module "../interfaces/task.interface" {
20
+ export namespace Katmer {
21
+ export interface TaskActions {
22
+ gather_facts?: GatherFactsModuleOptions
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * You can pass `true` to use sensible defaults with controller caching and target-side persistence.
28
+ * If a string array is provided, it will be used as the `modules` list.
29
+ */
30
+ export type GatherFactsModuleOptions =
31
+ | {
32
+ /**
33
+ * fastfetch modules to fetch. See https://github.com/fastfetch-cli/fastfetch/wiki/Support+Status#available-modules for available modules.
34
+ * @defaultValue ["bios","board","cpu","cpucache","datetime","disk","dns","gpu","host","initsystem","kernel","locale","localip","memory","os","packages","physicaldisk","publicip","shell","swap","terminal","title","tpm","uptime","users","version","wifi"]
35
+ */
36
+ modules: string[]
37
+ /**
38
+ * GitHub tag to use (e.g., `"2.16.0"`).
39
+ * If omitted, the module uses the **latest release**, subject to the local release cache
40
+ * controlled by {@link GatherFactsModuleOptions.release_ttl_days | `release_ttl_days`}.
41
+ */
42
+ version?: string
43
+ /**
44
+ * Directory on the **controller** where release metadata and downloaded zip files are cached.
45
+ * @defaultValue system temp dir, e.g. `os.tmpdir()/katmer-fastfetch-cache`
46
+ */
47
+ cache_dir?: string
48
+ /**
49
+ * Time-to-live (in days) for the local **release metadata** cache.
50
+ * Within this period, the module avoids querying GitHub’s API again.
51
+ * @defaultValue 3
52
+ */
53
+ release_ttl_days?: number
54
+ /**
55
+ * Persistent directory on the **target** where the fastfetch binary is placed.
56
+ * If the same version already exists there, upload/download is skipped.
57
+ * @defaultValue POSIX: `~/.katmer/bin` • Windows: `%USERPROFILE%\.katmer\bin`
58
+ */
59
+ target_dir?: string
60
+ /**
61
+ * When the provider OS cannot be determined, try all supported OS binaries (linux/darwin/windows)
62
+ * in that order until one succeeds.
63
+ * @defaultValue true
64
+ */
65
+ fallback_when_unknown?: boolean
66
+ }
67
+ | boolean
68
+ | string[]
69
+
70
+ export interface GatherFactsModuleResult extends ModuleCommonReturn {
71
+ /** Mapped fastfetch `--format json` output to `{ [type|lowercase]: result }` */
72
+ facts?: Record<string, any>
73
+ /** Which OS binary ran successfully */
74
+ used_os?: OsKey
75
+ /** Release version/tag used */
76
+ version?: string
77
+ }
78
+
79
+ const GITHUB_API =
80
+ "https://api.github.com/repos/fastfetch-cli/fastfetch/releases"
81
+
82
+ /**
83
+ * Gather target facts using the [**fastfetch** CLI](https://github.com/fastfetch-cli/fastfetch) (zero external deps on the target).
84
+ *
85
+ * @remarks
86
+ * - The module **fetches a prebuilt fastfetch binary from GitHub Releases** (once, with a TTL cache on the controller),
87
+ * uploads (or remote-downloads) the matching binary to the target into a persistent directory
88
+ * (e.g. `~/.katmer/bin` on POSIX or `%USERPROFILE%\.katmer\bin` on Windows), and runs it with `--format json`.
89
+ * - It relies on the provider's pre-detected OS/arch to pick the right asset.
90
+ * If the OS is unknown and {@link GatherFactsModuleOptions.fallback_when_unknown | `fallback_when_unknown`} is true,
91
+ * it will **try all supported OS binaries** (linux → darwin → windows) until one succeeds.
92
+ * - **Idempotent on target**: if the same fastfetch version already exists on the target path, the binary won't be re-uploaded/re-downloaded.
93
+ * - **Cached on controller**: GitHub releases and zip payloads are cached locally under {@link GatherFactsModuleOptions.cache_dir | `cache_dir`}.
94
+ * Releases are re-queried only after {@link GatherFactsModuleOptions.release_ttl_days | `release_ttl_days`} days.
95
+ * - Works with both **SSH** and **Local** providers:
96
+ * - SSH: uploads to `~/.katmer/bin/fastfetch[-os].exe` (or remote-downloads if upload fails).
97
+ * - Local: runs the cached controller binary directly (still keeps controller cache/TTL).
98
+ *
99
+ * @examples
100
+ * ```yaml
101
+ * - name: Gather target facts via fastfetch
102
+ * gather_facts:
103
+ * version: "2.16.0" # optional, defaults to cached-latest within TTL
104
+ * cache_dir: "/var/cache/katmer" # controller cache for releases & zips
105
+ * release_ttl_days: 3 # only re-check GitHub after 3 days
106
+ * target_dir: "~/.katmer/bin" # where the binary lives on target
107
+ *
108
+ * - name: Fallback across OSes (OS unknown → try all; darwin wins)
109
+ * gather_facts:
110
+ * fallback_when_unknown: true
111
+ *
112
+ * - name: Gather facts on localhost
113
+ * targets: local
114
+ * gather_facts:
115
+ * release_ttl_days: 5
116
+ * ```
117
+ *
118
+ */
119
+ export class GatherFactsModule extends KatmerModule<
120
+ GatherFactsModuleOptions,
121
+ GatherFactsModuleResult,
122
+ KatmerProvider
123
+ > {
124
+ static name = "gather_facts" as const
125
+
126
+ constraints = {
127
+ platform: {
128
+ any: true
129
+ }
130
+ } satisfies ModuleConstraints
131
+
132
+ async check(): Promise<void> {}
133
+ async initialize(): Promise<void> {}
134
+ async cleanup(): Promise<void> {}
135
+
136
+ async execute(ctx: Katmer.TaskContext): Promise<GatherFactsModuleResult> {
137
+ const defaultModules = [
138
+ "bios",
139
+ "board",
140
+ "cpu",
141
+ "cpucache",
142
+ "datetime",
143
+ "disk",
144
+ "dns",
145
+ "gpu",
146
+ "host",
147
+ "initsystem",
148
+ "kernel",
149
+ "locale",
150
+ "localip",
151
+ "memory",
152
+ "os",
153
+ "packages",
154
+ "physicaldisk",
155
+ "publicip",
156
+ "shell",
157
+ "swap",
158
+ "terminal",
159
+ "title",
160
+ "tpm",
161
+ "uptime",
162
+ "users",
163
+ "version",
164
+ "wifi"
165
+ ]
166
+ const opts = Object.assign(
167
+ {
168
+ cache_dir: path.join(os.tmpdir(), "katmer-fastfetch-cache"),
169
+ release_ttl_days: 3,
170
+ fallback_when_unknown: true
171
+ },
172
+ Array.isArray(this.params) ? { modules: this.params }
173
+ : typeof this.params !== "boolean" ? this.params
174
+ : {
175
+ modules: defaultModules
176
+ }
177
+ )
178
+
179
+ if (!opts.modules || opts.modules.length === 0) {
180
+ return {
181
+ changed: false,
182
+ failed: true,
183
+ msg: "fastfetch facts: no modules specified"
184
+ }
185
+ }
186
+
187
+ const fFetchArgs = `--format json --structure ${opts.modules.join(":")}`
188
+
189
+ // 1) Decide OS from provider (set during connect/ensureReady)
190
+ const fam = (ctx.provider.os?.family || "unknown") as
191
+ | "linux"
192
+ | "darwin"
193
+ | "windows"
194
+ | "unknown"
195
+ const arch = (ctx.provider.os?.arch || "").toLowerCase()
196
+
197
+ // 2) Resolve release info with controller-side TTL cache
198
+ const rel = await resolveReleaseCached(
199
+ opts.version,
200
+ opts.cache_dir,
201
+ opts.release_ttl_days
202
+ )
203
+
204
+ // 3) Compute desired OS order
205
+ const primaryOs: OsKey | undefined =
206
+ fam === "linux" ? "linux"
207
+ : fam === "darwin" ? "darwin"
208
+ : fam === "windows" ? "windows"
209
+ : undefined
210
+
211
+ const order: OsKey[] =
212
+ primaryOs ?
213
+ ([
214
+ primaryOs,
215
+ ...(["linux", "darwin", "windows"] as OsKey[]).filter(
216
+ (k) => k !== primaryOs
217
+ )
218
+ ] as OsKey[])
219
+ : opts.fallback_when_unknown ? (["linux", "darwin", "windows"] as OsKey[])
220
+ : []
221
+
222
+ if (order.length === 0) {
223
+ return {
224
+ changed: false,
225
+ failed: true,
226
+ msg: "fastfetch facts: target OS could not be determined and fallback disabled"
227
+ }
228
+ }
229
+
230
+ // 4) Run per-provider flow
231
+ if (ctx.provider instanceof LocalProvider) {
232
+ // Local: ensure local binary, then run directly
233
+ const localOs: OsKey =
234
+ process.platform === "win32" ? "windows"
235
+ : process.platform === "darwin" ? "darwin"
236
+ : "linux"
237
+ const asset = pickAssetFor(rel, localOs, normalizeNodeArch(process.arch))
238
+ const binPath = await ensureLocalBinary(opts.cache_dir, localOs, asset)
239
+ const runCmd =
240
+ localOs === "windows" ?
241
+ `${sh(binPath)} ${fFetchArgs}`
242
+ : `${sh(binPath)} ${fFetchArgs}`
243
+
244
+ const r = await ctx.execSafe(runCmd)
245
+ const facts = parseFacts(r.stdout?.trim() || "")
246
+ if (r.code === 0 && facts) {
247
+ return {
248
+ changed: false,
249
+ facts,
250
+ used_os: localOs,
251
+ version: rel.tag_name
252
+ }
253
+ }
254
+ return {
255
+ changed: false,
256
+ failed: true,
257
+ msg: `fastfetch failed locally: ${(r.stderr || r.stdout || "").trim()}`
258
+ }
259
+ }
260
+
261
+ if (!(ctx.provider instanceof SSHProvider)) {
262
+ return {
263
+ changed: false,
264
+ failed: true,
265
+ msg: "fastfetch facts: unsupported provider"
266
+ }
267
+ }
268
+
269
+ // SSH: For each OS in order, ensure (via remote download to target dir) and run
270
+ for (const osKey of order) {
271
+ const asset = pickAssetFor(rel, osKey, arch)
272
+ if (!asset) continue
273
+
274
+ try {
275
+ const binPath =
276
+ osKey === "windows" ?
277
+ await ensureRemoteWindows(
278
+ ctx,
279
+ asset.browser_download_url,
280
+ rel.tag_name,
281
+ opts.target_dir
282
+ )
283
+ : await ensureRemotePosix(
284
+ ctx,
285
+ asset.browser_download_url,
286
+ rel.tag_name,
287
+ opts.target_dir
288
+ )
289
+
290
+ const r =
291
+ osKey === "windows" ?
292
+ await psRaw(ctx, `& ${psq(binPath)} ${fFetchArgs}`)
293
+ : await ctx.execSafe(`${sh(binPath)} ${fFetchArgs}`)
294
+
295
+ const facts = parseFacts(r.stdout?.trim() || "")
296
+ if (r.code === 0 && facts) {
297
+ return {
298
+ changed: false,
299
+ facts,
300
+ used_os: osKey,
301
+ version: rel.tag_name
302
+ }
303
+ }
304
+ } catch (e: any) {
305
+ // try next OS if fallback is enabled
306
+ ctx.logger?.debug?.({
307
+ msg: `fastfetch ${osKey} path failed`,
308
+ error: String(e)
309
+ })
310
+ }
311
+ }
312
+
313
+ return {
314
+ changed: false,
315
+ failed: true,
316
+ msg: "fastfetch failed on target for all attempted OS paths"
317
+ }
318
+ }
319
+ }
320
+
321
+ /* ───────────────────────── controller-side helpers ───────────────────────── */
322
+
323
+ function rand() {
324
+ return crypto.randomBytes(5).toString("hex")
325
+ }
326
+ function sh(p: string) {
327
+ return JSON.stringify(p)
328
+ }
329
+ function psq(s: string) {
330
+ return `'${String(s).replace(/'/g, "''")}'`
331
+ }
332
+
333
+ function normalizeNodeArch(a: NodeJS.Process["arch"]): string {
334
+ switch (a) {
335
+ case "x64":
336
+ return "x86_64"
337
+ case "arm64":
338
+ return "arm64"
339
+ case "arm":
340
+ return "arm"
341
+ case "ia32":
342
+ return "i386"
343
+ default:
344
+ return a
345
+ }
346
+ }
347
+
348
+ async function resolveReleaseCached(
349
+ version: string | undefined,
350
+ cacheDir: string,
351
+ ttlDays: number
352
+ ): Promise<{
353
+ tag_name: string
354
+ assets: Array<{ name: string; browser_download_url: string }>
355
+ }> {
356
+ const relDir = path.join(cacheDir, "releases")
357
+ await fs.ensureDir(relDir)
358
+ const key = version ? `tag-${version}` : "latest"
359
+ const cacheFile = path.join(relDir, `${key}.json`)
360
+
361
+ const now = Date.now()
362
+ const ttlMs = Math.max(1, ttlDays) * 24 * 3600 * 1000
363
+
364
+ if (await fs.pathExists(cacheFile)) {
365
+ try {
366
+ const cached = JSON.parse(await fs.readFile(cacheFile, "utf8")) as {
367
+ fetchedAt: number
368
+ data: {
369
+ tag_name: string
370
+ assets: Array<{ name: string; browser_download_url: string }>
371
+ }
372
+ }
373
+ if (
374
+ cached?.fetchedAt &&
375
+ now - cached.fetchedAt < ttlMs &&
376
+ cached.data?.tag_name
377
+ ) {
378
+ return cached.data
379
+ }
380
+ } catch {
381
+ // ignore; will refetch
382
+ }
383
+ }
384
+
385
+ const url =
386
+ version ?
387
+ `${GITHUB_API}/tags/${encodeURIComponent(version)}`
388
+ : `${GITHUB_API}/latest`
389
+ const r = await fetch(url, { headers: { "User-Agent": "katmer-fastfetch" } })
390
+ if (!r.ok)
391
+ throw new Error(`GitHub releases fetch failed: ${r.status} ${r.statusText}`)
392
+ const data = (await r.json()) as {
393
+ tag_name: string
394
+ assets: Array<{ name: string; browser_download_url: string }>
395
+ }
396
+
397
+ await fs.writeFile(
398
+ cacheFile,
399
+ JSON.stringify({ fetchedAt: now, data }),
400
+ "utf8"
401
+ )
402
+ return data
403
+ }
404
+
405
+ /**
406
+ * Choose the best matching asset for a given OS + arch.
407
+ * First try strict OS+ARCH zip names, then fall back to OS-only.
408
+ * Throws if not found.
409
+ */
410
+ function pickAssetFor(
411
+ rel: {
412
+ tag_name: string
413
+ assets: Array<{ name: string; browser_download_url: string }>
414
+ },
415
+ osKey: OsKey,
416
+ arch: string
417
+ ) {
418
+ const list = rel.assets
419
+ const archTokens = tokensForArch(arch)
420
+
421
+ const osExpr =
422
+ osKey === "darwin" ? "(macos|darwin)"
423
+ : osKey === "windows" ? "(windows|win)"
424
+ : "linux"
425
+
426
+ const strict = archTokens.map(
427
+ (t) => new RegExp(`fastfetch-${osExpr}-${t}\\.zip$`, "i")
428
+ )
429
+ for (const a of list) {
430
+ if (strict.some((rx) => rx.test(a.name))) return a
431
+ }
432
+
433
+ // fallback looser zip names (just OS mention and ".zip")
434
+ const loose = new RegExp(`(${osExpr}).*\\.zip$`, "i")
435
+ const found = list.find((a) => loose.test(a.name))
436
+ if (found) return found
437
+
438
+ throw new Error(`could not find fastfetch asset for ${osKey}-${arch}`)
439
+ }
440
+
441
+ function tokensForArch(arch: string): string[] {
442
+ const a = arch.toLowerCase()
443
+ if (/^(x64|x86_64|amd64)$/.test(a)) return ["x86_64", "amd64", "x64"]
444
+ if (/^(aarch64|arm64)$/.test(a)) return ["aarch64", "arm64"]
445
+ if (/^(arm|armv7|armhf)$/.test(a)) return ["armv7", "armhf", "arm"]
446
+ if (/^(i386|x86|386)$/.test(a)) return ["i386", "x86", "386"]
447
+ return [a]
448
+ }
449
+
450
+ /**
451
+ * Controller-side ensure + extract (used by LocalProvider).
452
+ * Writes the binary into `${cacheDir}/bin/fastfetch-{os}` (or .exe).
453
+ */
454
+ async function ensureLocalBinary(
455
+ cacheDir: string,
456
+ osKey: OsKey,
457
+ asset: { name: string; browser_download_url: string }
458
+ ) {
459
+ const dlDir = path.join(cacheDir, "downloads")
460
+ const binDir = path.join(cacheDir, "bin")
461
+ await fs.ensureDir(dlDir)
462
+ await fs.ensureDir(binDir)
463
+
464
+ const zipPath = path.join(dlDir, asset.name)
465
+ if (!(await fs.pathExists(zipPath))) {
466
+ const buf = await (
467
+ await fetch(asset.browser_download_url, {
468
+ headers: { "User-Agent": "katmer-fastfetch" }
469
+ })
470
+ ).arrayBuffer()
471
+ await fs.writeFile(zipPath, Buffer.from(buf))
472
+ }
473
+
474
+ const zip = new AdmZip(zipPath)
475
+ const entries = zip.getEntries() as any[]
476
+ const target = entries.find((e) => {
477
+ const n = String(e.entryName)
478
+ return (
479
+ /(^|\/)fastfetch(\.exe)?$/i.test(n) || /(^|\/)bin\/fastfetch$/i.test(n)
480
+ )
481
+ })
482
+ if (!target) throw new Error(`Binary not found inside ${asset.name}`)
483
+
484
+ const outPath = path.join(
485
+ binDir,
486
+ osKey === "windows" ? "fastfetch.exe" : `fastfetch-${osKey}`
487
+ )
488
+ await fs.writeFile(outPath, target.getData())
489
+ if (osKey !== "windows") await fs.chmod(outPath, 0o755)
490
+ return outPath
491
+ }
492
+
493
+ /* ───────────────────────── remote (SSH) ensure helpers ───────────────────────── */
494
+
495
+ async function ensureRemotePosix(
496
+ ctx: Katmer.TaskContext,
497
+ assetUrl: string,
498
+ versionTag: string,
499
+ explicitTargetDir?: string
500
+ ): Promise<string> {
501
+ // Will install to $HOME/.katmer/bin (or explicitTargetDir), keep fastfetch.version for idempotency.
502
+ const id = rand()
503
+ const cmd = [
504
+ `URL=${sh(assetUrl)}; TAG=${sh(versionTag)};`,
505
+ `HOME_DIR="\${HOME:-$PWD}";`,
506
+ explicitTargetDir ?
507
+ `T=${sh(explicitTargetDir)};`
508
+ : `T="$HOME_DIR/.katmer/bin";`,
509
+ `mkdir -p "$T";`,
510
+ `BIN="$T/fastfetch"; VER="$T/fastfetch.version";`,
511
+ `[ -x "$BIN" ] && [ -f "$VER" ] && [ "$(cat "$VER")" = "$TAG" ] && { echo "$BIN"; exit 0; };`,
512
+ `TMP="$(mktemp -d)"; ZIP="$TMP/ff.zip";`,
513
+ `if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$ZIP" "$URL";`,
514
+ `elif command -v wget >/dev/null 2>&1; then wget -qO "$ZIP" "$URL";`,
515
+ `else echo "no curl/wget found" >&2; rm -rf "$TMP"; exit 90; fi;`,
516
+ `if command -v unzip >/dev/null 2>&1; then unzip -o "$ZIP" -d "$TMP" >/dev/null;`,
517
+ `elif command -v busybox >/dev/null 2>&1; then busybox unzip "$ZIP" -d "$TMP" >/dev/null;`,
518
+ `else echo "no unzip available" >&2; rm -rf "$TMP"; exit 91; fi;`,
519
+ `F="$(find "$TMP" -type f \\( -name fastfetch -o -name fastfetch.exe \\) | head -n1)";`,
520
+ `[ -z "$F" ] && { echo "binary not found" >&2; rm -rf "$TMP"; exit 92; };`,
521
+ `install -D -m 0755 "$F" "$BIN";`,
522
+ `printf "%s" "$TAG" > "$VER";`,
523
+ `rm -rf "$TMP";`,
524
+ `echo "$BIN"`
525
+ ].join(" ")
526
+
527
+ const r = await ctx.execSafe(cmd)
528
+ if (r.code !== 0 || !r.stdout?.trim()) {
529
+ throw new Error(
530
+ (r.stderr || r.stdout || `posix ensure failed (${r.code})`).trim()
531
+ )
532
+ }
533
+ return r.stdout.trim().split(/\r?\n/).slice(-1)[0] // last echo
534
+ }
535
+
536
+ async function ensureRemoteWindows(
537
+ ctx: Katmer.TaskContext,
538
+ assetUrl: string,
539
+ versionTag: string,
540
+ explicitTargetDir?: string
541
+ ): Promise<string> {
542
+ const script = `
543
+ $ErrorActionPreference='Stop';
544
+ $URL = ${psq(assetUrl)};
545
+ $TAG = ${psq(versionTag)};
546
+ $HOME = $env:USERPROFILE;
547
+ $T = ${explicitTargetDir ? psq(explicitTargetDir) : `Join-Path $HOME ".katmer\\bin"`};
548
+ $BIN = Join-Path $T "fastfetch.exe";
549
+ $VER = Join-Path $T "fastfetch.version";
550
+ New-Item -ItemType Directory -Force -Path $T | Out-Null;
551
+ if ((Test-Path $BIN) -and (Test-Path $VER) -and ((Get-Content $VER -Raw).Trim() -eq $TAG)) {
552
+ Write-Output $BIN; exit 0
553
+ }
554
+ $zip = Join-Path $env:TEMP ("ff_" + [guid]::NewGuid().ToString() + ".zip");
555
+ Invoke-WebRequest -Uri $URL -OutFile $zip -UseBasicParsing;
556
+ $tmpDir = Join-Path $env:TEMP ("ff_" + [guid]::NewGuid().ToString());
557
+ Add-Type -AssemblyName System.IO.Compression.FileSystem;
558
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $tmpDir);
559
+ $ff = Get-ChildItem -Path $tmpDir -Recurse -File -Filter fastfetch.exe | Select-Object -First 1;
560
+ if (-not $ff) { throw "fastfetch.exe not found in zip" }
561
+ New-Item -ItemType Directory -Force -Path $T | Out-Null;
562
+ Copy-Item -Force $ff.FullName $BIN;
563
+ Set-Content -Path $VER -Value $TAG -NoNewline;
564
+ Remove-Item -Recurse -Force $tmpDir; Remove-Item -Force $zip;
565
+ Write-Output $BIN
566
+ `
567
+ const r = await psRaw(ctx, script)
568
+ if (r.code !== 0 || !r.stdout?.trim()) {
569
+ throw new Error(
570
+ (r.stderr || r.stdout || `windows ensure failed (${r.code})`).trim()
571
+ )
572
+ }
573
+ return r.stdout.trim().split(/\r?\n/).slice(-1)[0]
574
+ }
575
+
576
+ /* Windows runner helper (SSH) */
577
+ async function psRaw(ctx: Katmer.TaskContext, script: string) {
578
+ const wrapped = `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${psq(script)}`
579
+ return ctx.execSafe(wrapped)
580
+ }
581
+
582
+ /* ───────────────────────── parsing ───────────────────────── */
583
+
584
+ /**
585
+ * fastfetch --format json output is typically an array of { type, result }.
586
+ * We normalize into an object keyed by lowercased type.
587
+ */
588
+ function parseFacts(s: string) {
589
+ try {
590
+ const parsed = JSON.parse(s)
591
+ if (Array.isArray(parsed)) {
592
+ return parsed.reduce<Record<string, any>>((acc, item) => {
593
+ if (item.type) {
594
+ if (item.result) {
595
+ acc[String(item.type).toLowerCase()] = item.result
596
+ } else {
597
+ acc[String(item.type).toLowerCase()] = item
598
+ }
599
+ }
600
+ return acc
601
+ }, {})
602
+ }
603
+ } catch {}
604
+ return undefined
605
+ }