@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,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
|
+
}
|