hostctl 0.1.32

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 ADDED
@@ -0,0 +1,1027 @@
1
+ # hostctl
2
+
3
+ `hostctl` is a modern, lightweight alternative to tools like Ansible, designed with simplicity and ease of use at its core. The primary goal of `hostctl` is to provide a straightforward and consistent way to execute scripts on both local and remote hosts. It aims for portability across all POSIX-style systems.
4
+
5
+ For detailed information on Hostctl's architecture and design, please see the [ARCHITECTURE.md](ARCHITECTURE.md) document.
6
+ For notes on developing and contributing to Hostctl, refer to the [CONTRIBUTING.md](CONTRIBUTING.md) document.
7
+
8
+ ## Quick start
9
+
10
+ ### Install age
11
+
12
+ ```
13
+ ❯ brew install age
14
+
15
+ ```
16
+
17
+ ### Generate keys
18
+
19
+ ```
20
+ ~
21
+ ❯ mkdir -p ~/.hostctl/age
22
+
23
+ ~
24
+ ❯ cd .hostctl
25
+
26
+ ~/.hostctl
27
+ ❯ age-keygen -o age/johndoe.priv
28
+ Public key: age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
29
+
30
+ ~/.hostctl
31
+ ❯ age-keygen -y age/johndoe.priv
32
+ age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
33
+
34
+ ~/.hostctl
35
+ ❯ age-keygen -y age/johndoe.priv > age/johndoe.pub
36
+
37
+ ~/.hostctl
38
+ ❯ cat age/johndoe.priv
39
+ # created: 2025-05-24T14:24:12-05:00
40
+ # public key: age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
41
+ AGE-SECRET-KEY-1G403WGS9Z599V2NMK923LWCUFJGRR6LXZ72A3UT95U7AMM5SADESZH7M7P
42
+
43
+ ~/.hostctl
44
+ ❯ cat age/johndoe.pub
45
+ age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
46
+
47
+ ~/.hostctl
48
+ ❯ age-keygen -o age/janedoe.priv
49
+ Public key: age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
50
+
51
+ ~/.hostctl
52
+ ❯ age-keygen -y age/janedoe.priv > age/janedoe.pub
53
+
54
+ ~/.hostctl
55
+ ❯ cat age/janedoe.priv
56
+ # created: 2025-05-24T14:26:36-05:00
57
+ # public key: age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
58
+ AGE-SECRET-KEY-1M4P4NQY8HEUGRJMWSPQ35J5W2SMUR7VLQULYX7HQ639C6XX8NGPQQ8Z5M7
59
+
60
+ ~/.hostctl
61
+ ❯ cat age/janedoe.pub
62
+ age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
63
+ ```
64
+
65
+ ### Create hostctl config file
66
+
67
+ ```
68
+ ~
69
+ ❯ mkdir -p ~/.hostctl/age
70
+
71
+ ~
72
+ ❯ cd .hostctl
73
+
74
+ ~/.hostctl
75
+ ❯ cat <<EOF > hostctl.yaml
76
+ hosts:
77
+ debian-vm:
78
+ hostname: 192.168.56.11
79
+ user: vagrant
80
+ password: !secret vagrant-password
81
+ tags: [debian, testvm]
82
+
83
+ ubuntu-vm:
84
+ hostname: 192.168.56.15
85
+ user: vagrant
86
+ password: !secret vagrant-password
87
+ tags: [ubuntu, testvm]
88
+
89
+ secrets:
90
+ vagrant-password:
91
+ ids: johndoe, janedoe
92
+ value: vagrant
93
+
94
+ ids:
95
+ johndoe: age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
96
+ janedoe: age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
97
+ EOF
98
+
99
+ ~/.hostctl
100
+ ❯ tree
101
+ .
102
+ ├── age
103
+ │   ├── janedoe.priv
104
+ │   ├── janedoe.pub
105
+ │   ├── johndoe.priv
106
+ │   └── johndoe.pub
107
+ └── hostctl.yaml
108
+
109
+ 2 directories, 5 files
110
+ ```
111
+
112
+ #### Host Configuration Details
113
+
114
+ When defining hosts in your `hostctl.yaml`:
115
+
116
+ - The **key** for each entry under the `hosts:` map **is** the network identifier (hostname or IP address) for that host. This key is directly used as the `hostname` for connection and identification purposes.
117
+
118
+ For example:
119
+
120
+ ```yaml
121
+ hosts:
122
+ # The key '192.168.56.11' is the hostname.
123
+ 192.168.56.11:
124
+ alias: debian1 # An optional alias for convenience
125
+ user: vagrant
126
+ # Any 'hostname: some.other.address' field here would be ignored for connection.
127
+
128
+ # The key 'my-web-server' is treated as the hostname.
129
+ # Ensure 'my-web-server' is resolvable if it's not an IP.
130
+ my-web-server:
131
+ user: admin
132
+ ```
133
+
134
+ The `Host` object within `hostctl` stores this key as its `hostname` property. When the configuration is saved (e.g., after decryption or other modifications), this `hostname` (which was the original key) will be written to an explicit `hostname` field in the YAML output for that host entry. This ensures that even if the key was, for example, an IP address, it's preserved and clearly identifiable in the output YAML.
135
+
136
+ ### Run a task
137
+
138
+ The `hostctl run <script_path> [params...]` command executes the specified script on your local machine. Within the script, you can use the `context.ssh(...)` method to connect to remote hosts defined in your inventory and perform operations on them.
139
+
140
+ export AGE_IDS="~/.hostctl/age/johndoe.priv ~/.hostctl/age/janedoe.priv"
141
+
142
+ hostctl on  HEAD (dd3d7ff) [?] is 📦 v0.1.31 via 🥟 v1.2.10 via 🦕 via  v23.11.0 took 13s
143
+ ❯ hostctl run example/reachable.ts
144
+ run: /home/david/sync/projects/monopod/hostctl/example/reachable.ts {}
145
+ ✔ Enter your password
146
+ === Evaluation ===
147
+ ✓ Reachable
148
+ ✓ echo foo,bar,baz
149
+ ✓ echo foo,bar,baz
150
+ ✓ os
151
+ ✓ echo 1,1,1
152
+ ✓ echo 2,2,2
153
+ ✓ echo 3,3,4
154
+ === Result ===
155
+ { value: 'foo' }
156
+
157
+ hostctl on  HEAD (dd3d7ff) [?] is 📦 v0.1.31 via 🥟 v1.2.10 via 🦕 via  v23.11.0 took 17s
158
+ ❯ hostctl run example/echo.ts args:foo,bar
159
+ run: /home/david/sync/projects/monopod/hostctl/example/echo.ts { args: [ 'foo', 'bar' ] }
160
+ === Evaluation ===
161
+ ✓ echo foo,bar
162
+ === Result ===
163
+ undefined
164
+
165
+ ```
166
+
167
+ ### Encrypt and decrypt inventory
168
+
169
+
170
+ ```
171
+
172
+ hostctl on  main [!] is 📦 v0.1.31 via 🥟 v1.2.10 via  v23.11.0
173
+ ❯ hostctl inventory -c example/hostctl.yaml encrypt
174
+ Encrypting inventory file: example/hostctl.yaml
175
+
176
+ hostctl on  main [!] is 📦 v0.1.31 via 🥟 v1.2.10 via  v23.11.0
177
+ ❯ cat example/hostctl.yaml
178
+ hosts:
179
+ debian-vm:
180
+ user: vagrant
181
+ password: !secret vagrant-password
182
+ tags: - debian - test - example - debian-vm
183
+ ubuntu-vm:
184
+ user: vagrant
185
+ password: !secret vagrant-password
186
+ tags: - ubuntu - test - example - ubuntu-vm
187
+ secrets:
188
+ vagrant-password:
189
+ ids: - johndoe - janedoe
190
+ value: |
191
+ -----BEGIN AGE ENCRYPTED FILE-----
192
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRc0NING5YRlpJdFFoQjJS
193
+ MGdzdVIzWElkMDBPM2RkNVE2ZUpucDhGVVN3CnpsdlpxQzJWaTRwSXl0WmxUT2tJ
194
+ bER5SEk5Wm9IWmhTVXkrNFRpL3BsbzAKLT4gWDI1NTE5IFZ2OU94aThZQXJQNjl5
195
+ ZEk1eWg0QWE5T2pKQVA2a1dvOC9FeXAvL3Q2RFEKbHpGUzJiU1dMNEV4OHFkd0pt
196
+ dS9TSVEwRlU2ZW9mUnhNdk9JdmEwVGU1RQotLS0gdkM3VmwvYzNEejE4OUU2blJI
197
+ d3NzVzZsREJ6Q3NjbEh2ai9qc3JxMGVOVQrgAqyjMEvOF2ifd02P/fr/AJzVKGdO
198
+ qjcfnTSO6aztc8oLxqvvb+w=
199
+ -----END AGE ENCRYPTED FILE-----
200
+ ids:
201
+ johndoe: - age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
202
+ janedoe: - age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
203
+
204
+ hostctl on  main [!] is 📦 v0.1.31 via 🥟 v1.2.10 via  v23.11.0
205
+ ❯ AGE_IDS="example/sample-age-ids/*.priv" hostctl inventory -c example/hostctl.yaml decrypt -v
206
+ Decrypting inventory file: example/hostctl.yaml
207
+ Decrypted with one or more of the following private keys:
208
+ example/sample-age-ids/johndoe.priv
209
+ example/sample-age-ids/janedoe.priv
210
+
211
+ hostctl on  main [!] is 📦 v0.1.31 via 🥟 v1.2.10 via  v23.11.0
212
+ ❯ cat example/hostctl.yaml
213
+ hosts:
214
+ debian-vm:
215
+ user: vagrant
216
+ password: !secret vagrant-password
217
+ tags: - debian - test - example - debian-vm
218
+ ubuntu-vm:
219
+ user: vagrant
220
+ password: !secret vagrant-password
221
+ tags: - ubuntu - test - example - ubuntu-vm
222
+ secrets:
223
+ vagrant-password:
224
+ ids: - johndoe - janedoe
225
+ value: vagrant
226
+ ids:
227
+ johndoe: - age1jx3ec0y5hctkfwfut4p8cj08ulcezqex8r8slwlwgwy8k2plx5ksdzy8ll
228
+ janedoe: - age1qdqv054hmfmnxk56dkjm3cq8qz4lxfl7tvqfp0rqdl0nyygjlccqw0ly40
229
+
230
+ ```
231
+
232
+ ## Vision
233
+
234
+ The vision for `hostctl` is to be an Ansible replacement that is significantly easier to use, with simple and intuitive semantics. It empowers users to manage and interact with hosts efficiently, whether for development, operations, or personal use.
235
+
236
+ ## Key Features
237
+
238
+ * **Simplified Host Interaction:** Run scripts seamlessly on local or remote machines.
239
+ * **Cross-Platform Portability:** Designed to work consistently across POSIX-compliant systems.
240
+ * **Flexible Scripting:** Write `hostctl` scripts in JavaScript or TypeScript.
241
+
242
+ ## Core Tasks
243
+
244
+ `hostctl` includes a library of core tasks that can be used within your scripts to perform common system administration operations. These tasks are designed to be idempotent and portable across POSIX-compliant systems.
245
+
246
+ ### File and Directory Management
247
+ - **`core.dir.copy`**: Recursively copies a directory.
248
+ - **`core.dir.create`**: Creates a directory.
249
+ - **`core.dir.exists`**: Checks if a directory exists.
250
+ - **`core.file.chgrp`**: Changes the group of a file.
251
+ - **`core.file.chmod`**: Changes the mode of a file.
252
+ - **`core.file.chown`**: Changes the owner of a file.
253
+ - **`core.file.copy`**: Copies a file.
254
+ - **`core.file.delete`**: Deletes a file.
255
+ - **`core.file.edit`**: Edits a file in place.
256
+ - **`core.file.exists`**: Checks if a file exists.
257
+ - **`core.file.grep`**: Searches for a pattern in a file.
258
+ - **`core.file.link`**: Creates a symbolic link.
259
+ - **`core.file.touch`**: Creates an empty file.
260
+
261
+ ### System and Host Management
262
+ - **`core.host.hostname`**: Get or set the hostname.
263
+ - **`core.host.info`**: Gathers information about the host (OS, architecture, etc.).
264
+ - **`core.host.os`**: Provides OS-specific information.
265
+ - **`core.hosts_file.add_entry`**: Adds an entry to `/etc/hosts`.
266
+ - **`core.system.reboot`**: Reboots the system.
267
+ - **`core.system.reboot_if_needed`**: Reboots the system if a reboot is required.
268
+ - **`core.system.reboot_needed`**: Checks if a reboot is required.
269
+ - **`core.system.shutdown`**: Shuts down the system.
270
+
271
+ ### User and Group Management
272
+ - **`core.group.create`**: Creates a user group.
273
+ - **`core.group.delete`**: Deletes a user group.
274
+ - **`core.group.exists`**: Checks if a user group exists.
275
+ - **`core.group.list`**: Lists user groups.
276
+ - **`core.group.modify`**: Modifies a user group.
277
+ - **`core.user.add_groups`**: Adds a user to groups.
278
+ - **`core.user.create`**: Creates a user.
279
+ - **`core.user.delete`**: Deletes a user.
280
+ - **`core.user.exists`**: Checks if a user exists.
281
+ - **`core.user.get_gid`**: Gets the group ID of a user.
282
+ - **`core.user.get_groups`**: Gets the groups a user belongs to.
283
+ - **`core.user.get_uid`**: Gets the user ID of a user.
284
+ - **`core.user.get_username`**: Gets the username of the current user.
285
+ - **`core.user.home_dir`**: Gets the home directory of a user.
286
+ - **`core.user.set_groups`**: Sets the groups for a user.
287
+ - **`core.user.set_shell`**: Sets the login shell for a user.
288
+ - **`core.whoami`**: Gets the current user's username.
289
+
290
+ ### Package Management
291
+ - **`core.pkg.info`**: Gathers information about a package.
292
+ - **`core.pkg.install`**: Installs a package.
293
+ - **`core.pkg.is_installed`**: Checks if a package is installed.
294
+ - **`core.pkg.remove`**: Removes a package.
295
+ - **`core.pkg.update`**: Updates a package.
296
+
297
+ ### Service and Process Management
298
+ - **`core.systemd.disable`**: Disables a systemd service.
299
+ - **`core.systemd.enable`**: Enables a systemd service.
300
+ - **`core.systemd.reload`**: Reloads a systemd service.
301
+ - **`core.systemd.restart`**: Restarts a systemd service.
302
+ - **`core.systemd.start`**: Starts a systemd service.
303
+ - **`core.systemd.status`**: Gets the status of a systemd service.
304
+ - **`core.systemd.stop`**: Stops a systemd service.
305
+
306
+ ### Version Control
307
+ - **`core.git.checkout`**: Checks out a git branch or commit.
308
+ - **`core.git.clone`**: Clones a git repository.
309
+ - **`core.git.pull`**: Pulls changes from a git repository.
310
+
311
+ ### Networking and Firewall
312
+ - **`core.ufw.deny`**: Denies traffic with UFW.
313
+ - **`core.ufw.disable`**: Disables UFW.
314
+ - **`core.ufw.enable`**: Enables UFW.
315
+ - **`core.ufw.install`**: Installs UFW.
316
+ - **`core.ufw.reload`**: Reloads UFW rules.
317
+
318
+ ### Miscellaneous
319
+ - **`core.echo`**: Prints a message.
320
+ - **`core.remote.runAllRemote`**: Runs a task on all remote hosts.
321
+ - **`core.ssh.copy_id`**: Copies an SSH key to a remote host.
322
+ - **`core.sudoers.check`**: Checks the sudoers file for a user.
323
+ - **`core.sudoers.grant-nopasswd`**: Grants passwordless sudo to a user.
324
+ - **`core.template.write`**: Writes a file from a template.
325
+ - **`core.k3s.k3sup-install`**: Installs k3s using k3sup.
326
+
327
+ ## CLI Usage
328
+
329
+ The `hostctl` command-line interface (CLI) is the primary way to interact with the tool.
330
+
331
+ ### Global Options
332
+
333
+ These options can be applied to most `hostctl` commands:
334
+
335
+ * `-q, --quiet`: Reduces the verbosity of output. Can be specified multiple times (e.g., `-qq`) for even less output.
336
+ * `-v, --verbose`: Increases the verbosity of output. Can be specified multiple times (e.g., `-vv`, `-vvv`) for more detailed logs. The default level typically shows warnings and errors.
337
+ * `-c, --config <path>`: Specifies the path to the configuration file (e.g., `my-config.yaml`) or an HTTP/HTTPS URL for a remote configuration. Defaults to `./hostctl.yaml`.
338
+ * `-o, --output <style>`: Sets the output style. Supported styles are `plain` (default, human-readable) and `json` (machine-readable).
339
+ * `--json`: A convenient shortcut for `-o json`.
340
+ * `--api`: Enables API mode, which implies JSON output and may include more structured data suitable for programmatic consumption.
341
+ * `-p, --password`: If set, `hostctl` will prompt the user for a password (e.g., for `sudo` access on hosts) if a task requires it.
342
+ * `-t, --tag <tags...>`: Filters the target hosts based on tags defined in the inventory. Only hosts matching all specified tags will be selected. For example, `-t webapp backend` or `-t region:europe`.
343
+
344
+ ### Commands
345
+
346
+ #### `run` (alias: `r`)
347
+
348
+ This is the **default command**. It executes a `hostctl` script.
349
+
350
+ **Usage:**
351
+
352
+ ```bash
353
+ hostctl run [package_or_bundle] [script] [script_arguments...]
354
+ ````
355
+
356
+ - **`package_or_bundle`** (optional):
357
+ - A path to a local directory containing the script or package (e.g., `./scripts`, `/opt/myproject`).
358
+ - A URL to a Git repository (e.g., `https://github.com/yourorg/hostctl-scripts.git`). `hostctl` will clone it temporarily.
359
+ - A path to a local `.zip` file (a "bundle") created by the `bundle` command.
360
+ - **`script`** (optional, especially if `package_or_bundle` is a bundle with a default script):
361
+ - The name or path of the script file to execute (e.g., `main.ts`, `deploy.js`).
362
+ - If `package_or_bundle` is provided, this is relative to the package root.
363
+ - If `package_or_bundle` is omitted, this is treated as a path relative to the current working directory.
364
+ - **`script_arguments...`** (optional):
365
+ - Any additional arguments passed directly to the script being executed. These can be simple values or key-value pairs.
366
+
367
+ **Options:**
368
+
369
+ - `-f, --file <path>`: Loads script parameters from a specified JSON file. The file should contain a single JSON object.
370
+ - `--params "<json_string>"`: Supplies script parameters as a JSON string directly on the command line (e.g., `--params '{"target_env":"production","retries":3}'`).
371
+ - `-r, --remote`: Executes the script on remote hosts (as defined in the inventory and filtered by tags) instead of on the local machine. Defaults to `false` (local execution).
372
+
373
+ **Examples:**
374
+
375
+ ```bash
376
+ # Run a local script
377
+ hostctl run deploy.ts
378
+
379
+ # Run a script from a local directory, passing arguments
380
+ hostctl run ./my-scripts provision.js server_name=app01 zone=us-west
381
+
382
+ # Run a script from a Git repository with parameters from a file
383
+ hostctl run https://github.com/user/hostctl-scripts.git setup-db.ts -f params.json --remote
384
+
385
+ # Run a script on remote hosts tagged 'database'
386
+ hostctl run -t database -r check-replication.ts
387
+ ```
388
+
389
+ **Task Script Return Value**
390
+
391
+ When a task script is executed using `hostctl run`, the value returned by the main task function (typically the default export of your script) is considered the primary result of the task.
392
+
393
+ - **Standard Output:** By default, `hostctl` will display this result along with other evaluation details.
394
+ - **JSON Output (`--json` or `-o json`):** If the `--json` flag (or its equivalent `-o json`) is specified, `hostctl` will output a JSON-serialized representation of the script's return value. When in JSON mode, this serialized result is the _only_ value rendered to standard output, suppressing other logs and evaluation details to ensure clean, machine-readable output. This is particularly useful for programmatic consumption of task results.
395
+
396
+ #### `exec` (alias: `e`)
397
+
398
+ Executes an ad-hoc shell command on the selected hosts.
399
+
400
+ **Usage:**
401
+
402
+ ```bash
403
+ hostctl exec "<command_string>"
404
+ ```
405
+
406
+ - **`<command_string>`**: The shell command to execute. Enclose in quotes if it contains spaces or special characters.
407
+
408
+ **Examples:**
409
+
410
+ ```bash
411
+ # Check disk space on all targeted hosts
412
+ hostctl exec "df -h"
413
+
414
+ # Restart a service on hosts tagged 'webserver'
415
+ hostctl exec -t webserver "sudo systemctl restart nginx"
416
+ ```
417
+
418
+ #### `bundle` (alias: `b`)
419
+
420
+ Packages a directory (typically a project with scripts and a `package.json`) into a deployable `.zip` bundle.
421
+
422
+ **Usage:**
423
+
424
+ ```bash
425
+ hostctl bundle [path]
426
+ ```
427
+
428
+ - **`[path]`** (optional): The path to the directory to bundle. Defaults to the current directory (`.`).
429
+
430
+ **Examples:**
431
+
432
+ ```bash
433
+ # Bundle the project in the current directory
434
+ hostctl bundle
435
+
436
+ # Bundle a project in a specific directory
437
+ hostctl bundle ./my-hostctl-project
438
+ ```
439
+
440
+ #### `inventory` (alias: `i`)
441
+
442
+ Manages and displays information about the host inventory.
443
+
444
+ **Usage:**
445
+
446
+ ```bash
447
+ hostctl inventory [subcommand]
448
+ ```
449
+
450
+ Running `hostctl inventory` without a subcommand prints a report of the current inventory.
451
+
452
+ **Subcommands:**
453
+
454
+ - **`encrypt`** (alias: `e`)
455
+ - Encrypts the inventory file (determined by the global `--config` option).
456
+ - **Usage:** `hostctl inventory encrypt`
457
+ - **`decrypt`** (alias: `d`)
458
+ - Decrypts the inventory file.
459
+
460
+ #### `runtime` (alias: `rt`)
461
+
462
+ Manages the `hostctl` runtime environment, particularly for Node.js scripts.
463
+
464
+ **Usage:**
465
+
466
+ ```bash
467
+ hostctl runtime [subcommand]
468
+ ```
469
+
470
+ Running `hostctl runtime` without a subcommand prints a report about the detected/configured runtime environment.
471
+
472
+ **Subcommands:**
473
+
474
+ - **`install`** (alias: `i`)
475
+ - Installs or verifies a temporary Node.js runtime environment on the local host if `hostctl` determines one is needed (e.g., if a global Node.js is not found or a specific version is required).
476
+ - **Usage:** `hostctl runtime install`
477
+
478
+ ## Inventory File Structure
479
+
480
+ `hostctl` uses a YAML file (defaulting to `hostctl.yaml`) to define its inventory of hosts, manage secrets, and specify encryption recipients. The file can contain three top-level keys: `hosts`, `secrets`, and `ids`.
481
+
482
+ ### `hosts` Section
483
+
484
+ This section defines the machines `hostctl` can interact with.
485
+
486
+ ```yaml
487
+ hosts:
488
+ server1.example.com:
489
+ user: deploy_user
490
+ ssh-key: !secret server1_ssh_key # References a secret
491
+ tags: [webserver, production, dc1]
492
+
493
+ 192.168.1.100:
494
+ alias: db_server_primary
495
+ user: admin
496
+ password: !secret db_admin_password
497
+ tags: [database, primary, dc1]
498
+
499
+ localhost:
500
+ user: mylocaluser
501
+ tags: [local, dev]
502
+
503
+ another.server.com:
504
+ # Minimal configuration, will use global defaults or prompt if needed
505
+ tags: [staging]
506
+ ```
507
+
508
+ - Each key under `hosts` (e.g., `server1.example.com`, `192.168.1.100`) is the hostname or IP address.
509
+ - **Properties for each host:**
510
+ - `alias` (optional): A friendly name for the host.
511
+ - `user` (optional): The username for SSH connections.
512
+ - `password` (optional): The password for SSH. Can be a plain string or a `!secret` reference (e.g., `!secret my_password_name`).
513
+ - `ssh-key` (optional): Path to an SSH private key file or the private key content itself. Can also be a `!secret` reference.
514
+ - `tags` (optional): A list of strings for categorizing and selecting hosts.
515
+
516
+ ### `secrets` Section
517
+
518
+ This section stores sensitive data, typically encrypted using AGE encryption.
519
+
520
+ ```yaml
521
+ secrets:
522
+ server1_ssh_key:
523
+ ids: [age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, admin_keys_group] # AGE public key or id group ref
524
+ value: |
525
+ -----BEGIN AGE ENCRYPTED FILE-----
526
+ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
527
+ -----END AGE ENCRYPTED FILE-----
528
+
529
+ db_admin_password:
530
+ ids: db_admins_group # Reference to an 'ids' group
531
+ value: 'plaintextpasswordbeforeencryption' # This will be encrypted by 'hostct inventory encrypt'
532
+ ```
533
+
534
+ - Each key under `secrets` (e.g., `server1_ssh_key`) is a unique name for the secret.
535
+ - **Properties for each secret:**
536
+ - `ids`: A single string or a list of strings specifying the AGE public keys or named recipient groups (from the `ids` section) that can decrypt this secret.
537
+ - `value`: The secret content. If `hostctl inventory encrypt` has been run, this will be an AGE encrypted block.
538
+
539
+ ### `ids` Section
540
+
541
+ This section defines named groups of AGE public keys, simplifying secret management.
542
+
543
+ ```yaml
544
+ ids:
545
+ admin_keys_group:
546
+ - age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Alice's public key
547
+ - age1yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy # Bob's public key
548
+
549
+ db_admins_group:
550
+ - admin_keys_group # Nested group reference
551
+ - age1zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz # CI server's public key
552
+
553
+ single_key_user: age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq
554
+ ```
555
+
556
+ - Each key under `ids` (e.g., `admin_keys_group`) is a unique name for a group of recipients.
557
+ - The value can be a single AGE public key string, a list of AGE public key strings, or a list containing references to other `ids` group names.
558
+
559
+ This structure allows for a flexible and secure way to manage host information and sensitive credentials. The `hostct inventory encrypt` and `hostct inventory decrypt` commands interact with the `secrets` and `ids` sections to manage encryption.
560
+
561
+ ### Inventory Decryption Behavior
562
+
563
+ When working with the inventory decryption functionality, it's important to understand these key behaviors:
564
+
565
+ 1. **Hostname Storage**: In the YAML configuration, hostnames are stored as the keys in the `hosts` map, not as properties inside each host object. When a host is serialized to YAML via `Host.toYAML()`, the `hostname` field is not included because it's represented by the map key.
566
+
567
+ 2. **Verbosity and Logging**: The inventory decryption process follows the general verbosity rules of hostctl:
568
+
569
+ - By default, only ERROR level messages are shown
570
+ - INFO level messages (like "No encrypted secrets found to decrypt. Inventory is already decrypted.") require the `-v` flag to be visible
571
+ - For idempotent operations like decrypting an already-decrypted inventory, you need to use the `-v` flag to see confirmation messages
572
+
573
+ 3. **Idempotent Operations**: The `inventory decrypt` command is designed to be idempotent - running it on an already decrypted inventory will not cause errors, but will only show a message if the `-v` flag is used.
574
+
575
+ ## Writing Tasks
576
+
577
+ A `hostctl` task is a TypeScript file that exports a default function created by the `task()` factory. This function receives a `context` object, which provides APIs for interacting with the system, hosts, and the user.
578
+
579
+ ### Task Structure
580
+
581
+ Here is a basic example of a task that echoes a message:
582
+
583
+ ```typescript
584
+ // src/core/echo.ts
585
+ import { task, type TaskContext, Verbosity } from '../runtime';
586
+
587
+ export interface EchoParams {
588
+ message: string;
589
+ }
590
+
591
+ export interface EchoResult {
592
+ echo: string;
593
+ }
594
+
595
+ async function run(context: TaskContext<EchoParams>): Promise<EchoResult> {
596
+ const { params, log } = context;
597
+ const { message } = params;
598
+ log(Verbosity.INFO, `Echoing message: ${message}`);
599
+ return { echo: message };
600
+ }
601
+
602
+ export default task(run, {
603
+ description: 'Echoes a message back to the caller.',
604
+ });
605
+ ```
606
+
607
+ ### The `TaskContext` API
608
+
609
+ The `context` object passed to your task's `run` function contains several helpful properties and methods for task execution:
610
+
611
+ - `params`: An object containing the parameters passed to the task.
612
+ - `id`: A unique identifier for the current task invocation.
613
+ - `host`: The `Host` object the task is currently running against. This is only available in a remote context (e.g., inside `context.ssh`).
614
+ - `config`: The resolved application configuration (`AppConfig`).
615
+ - `log(level, ...message)`: A function to log messages with a specific verbosity level. Levels are available from `Verbosity` (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`).
616
+ - `info(...)`, `debug(...)`, `warn(...)`, `error(...)`: Shortcuts for `log()` with the corresponding verbosity level.
617
+ - `exec(command, options)`: Executes a shell command on the target host. The `command` can be a string or an array of strings. It returns a `Promise<CommandResult>`, which contains `stdout`, `stderr`, `exitCode`, and boolean `success`/`failure` properties.
618
+ - `run(taskFn)`: Executes another `hostctl` task as a sub-task. This is the preferred way to compose tasks and reuse logic. For example: `await context.run(someOtherTask({ param: 'value' }))`.
619
+ - `ssh(tags, remoteTaskFn)`: Executes a function on a set of remote hosts matching the given tags, enabling parallel remote execution.
620
+ - `file`: An object providing a file system API (`read`, `write`, `exists`, `mkdir`, `rm`) that is contextualized to the target host (local or remote).
621
+ - `inventory(tags)`: Returns a list of `Host` objects from the inventory that match the given tags.
622
+ - `getPassword()`: Prompts the user for a password interactively.
623
+ - `getSecret(name)`: Retrieves a decrypted secret value from the configuration by its name.
624
+ - `exit(exitCode, message)`: Immediately terminates the `hostctl` application with a given exit code and optional message.
625
+
626
+ This API provides the core building blocks for creating powerful and flexible automation tasks with `hostctl`.
627
+
628
+ ## Writing hostctl Scripts
629
+
630
+ `hostctl` scripts are powerful tools for automating tasks on local and remote machines. They are written in TypeScript or JavaScript and follow a simple, consistent structure. This guide covers everything you need to know to write your own `hostctl` scripts.
631
+
632
+ ### The Basic Structure
633
+
634
+ A `hostctl` script is a module that exports a default `task` object. This object is created using the `task()` factory function, which takes your main logic function and an optional description.
635
+
636
+ ```typescript
637
+ // src/tasks/my-task.ts
638
+ import { task, type TaskContext, Verbosity } from 'hostctl';
639
+
640
+ // Define the parameters your task accepts
641
+ export interface MyTaskParams {
642
+ targetSystem: string;
643
+ enableFeature: boolean;
644
+ }
645
+
646
+ // Define the structure of the value your task returns
647
+ export interface MyTaskResult {
648
+ status: string;
649
+ rebootRequired: boolean;
650
+ }
651
+
652
+ // The core logic of your task
653
+ async function run(context: TaskContext<MyTaskParams>): Promise<MyTaskResult> {
654
+ const { params, log, exec } = context;
655
+
656
+ log(Verbosity.INFO, `Configuring ${params.targetSystem}...`);
657
+
658
+ if (params.enableFeature) {
659
+ await exec('echo "Feature enabled" >> /etc/config.conf');
660
+ }
661
+
662
+ const result = await exec('check-reboot-status');
663
+
664
+ return {
665
+ status: 'Configuration applied successfully.',
666
+ rebootRequired: result.stdout.includes('reboot required'),
667
+ };
668
+ }
669
+
670
+ // Export the task with a description that can use Handlebars templating
671
+ export default task(run, 'Configures {{targetSystem}} with feature: {{enableFeature}}');
672
+ ```
673
+
674
+ ### The `task()` Factory
675
+
676
+ The `task()` function is the entry point for defining your script.
677
+
678
+ `task(runFunction, description?)`
679
+
680
+ - `runFunction`: An `async` function that contains the core logic of your task. It receives a `TaskContext` object.
681
+ - `description` (optional): A string describing the task. It supports [Handlebars](https://handlebarsjs.com/) templating to dynamically insert parameter values, e.g., `"Deploying version {{version}} to {{environment}}"`.
682
+
683
+ ### The `TaskContext` Object
684
+
685
+ The `TaskContext` is the heart of the scripting API. It's passed to your `run` function and provides all the necessary tools to interact with the system.
686
+
687
+ - `params: TParams`: A strongly-typed object containing the parameters passed to your script from the command line or a params file.
688
+ - `log(level: LogLevel, ...message)`: A logging function for structured output.
689
+ - **Levels**: Use the `Verbosity` enum for log levels: `Verbosity.DEBUG`, `Verbosity.INFO`, `Verbosity.WARN`, `Verbosity.ERROR`, `Verbosity.TRACE`.
690
+ - **Usage**: `context.log(Verbosity.INFO as LogLevel, "This is an info message");`
691
+ - `info(...message)`: A convenience function that calls `log(Verbosity.INFO, ...message)`
692
+ - `error(...message)`: A convenience function that calls `log(Verbosity.ERROR, ...message)`
693
+ - `warn(...message)`: A convenience function that calls `log(Verbosity.WARN, ...message)`
694
+ - `debug(...message)`: A convenience function that calls `log(Verbosity.DEBUG, ...message)`
695
+ - `trace(...message)`: A convenience function that calls `log(Verbosity.TRACE, ...message)`
696
+ - `exec(command: string, options?: ExecOptions): Promise<CommandResult>`: Executes a shell command on the target host.
697
+ - **`options`**:
698
+ - `cwd`: The working directory for the command.
699
+ - `sudo`: If `true`, runs the command with `sudo`.
700
+ - `stdin`: A string or Buffer to pass to the command's standard input when it starts.
701
+ - `input`: An object where keys are regular expression strings (to match command output) and values are the corresponding strings to send to the command's standard input. Used for automating interactive prompts.
702
+ - `pty`: If `true`, allocates a pseudo-terminal, useful for interactive commands.
703
+ - `env`: An object of environment variables.
704
+ - **`CommandResult`**: The promise resolves to an object containing `stdout`, `stderr`, `exitCode`, and `signal`.
705
+ - `ssh(hostSelector, taskDefinition, params?): Promise<TRemoteReturn[] | Error>`: Executes another `hostctl` task on one or more remote hosts.
706
+ - **`hostSelector`**: A string, an array of strings, a `Host` object, or an array of `Host` objects to specify the target hosts from your inventory.
707
+ - **`taskDefinition`**: The task to execute on the remote host(s).
708
+ - **`params`**: The parameters to pass to the remote task.
709
+ - `file: FileOperations`: An object for performing file operations on the target host.
710
+ - `read(path)`: Reads the content of a file.
711
+ - `write(path, content)`: Writes content to a file.
712
+ - `exists(path)`: Checks if a file or directory exists.
713
+ - `mkdir(path)`: Creates a directory.
714
+ - `rm(path)`: Removes a file or directory.
715
+ - `cwd: Path`: The current working directory of the task.
716
+ - `app: App`: A reference to the main `App` instance, giving you access to global configuration, the full host inventory, and secret management (`context.app.getSecretValue()`).
717
+ - `host?: Host`: If the task is running on a specific host (e.g., via `ssh`), this object contains details about that host.
718
+
719
+ ### Handling Interactive Commands
720
+
721
+ For commands that require user input (e.g., password prompts, confirmation dialogs), `hostctl` provides a straightforward way to automate these interactions through the `input` option in `context.exec()`.
722
+
723
+ You don't need to create an `InteractionHandler` instance directly in your task script. Instead, you provide an object to the `input` option where:
724
+
725
+ - Each **key** is a string representing a regular expression. `hostctl` will monitor the command's standard output for lines matching this regex.
726
+ - Each **value** is the string that `hostctl` will send to the command's standard input when the corresponding key's regex is matched.
727
+
728
+ ```typescript
729
+ import { task, type TaskContext } from 'hostctl';
730
+
731
+ async function run(context: TaskContext): Promise<void> {
732
+ const { exec, log } = context;
733
+
734
+ log(Verbosity.INFO as LogLevel, 'Attempting an operation that requires interactive confirmation...');
735
+
736
+ const result = await exec('your-interactive-command --with-prompt', {
737
+ pty: true, // A PTY is usually required for interactive commands
738
+ input: {
739
+ // When the command outputs "Enter your password:", respond with "mysecretpassword"
740
+ 'Enter your password:': 'mysecretpassword',
741
+ // When the command outputs something like "Are you sure you want to continue (yes/no)?", respond with "yes"
742
+ 'Are you sure you want to continue \\(yes\\/no\\)\\?': 'yes',
743
+ },
744
+ });
745
+
746
+ if (result.exitCode === 0) {
747
+ log(Verbosity.INFO as LogLevel, 'Interactive command completed successfully.');
748
+ } else {
749
+ log(
750
+ Verbosity.ERROR as LogLevel,
751
+ `Interactive command failed with exit code ${result.exitCode}. Stderr: ${result.stderr}`,
752
+ );
753
+ }
754
+ }
755
+
756
+ export default task(run, 'Demonstrate handling of an interactive command');
757
+ ```
758
+
759
+ **Important Considerations:**
760
+
761
+ - **PTY Allocation**: Interactive commands usually require a pseudo-terminal (PTY) to behave correctly. Always set `pty: true` in the `ExecOptions` when using the `input` map for interactions.
762
+ - **Regex Specificity**: Make your regular expressions specific enough to avoid unintended matches. Test them thoroughly.
763
+ - **Order of Interactions**: If a command has multiple prompts, `hostctl` will respond to them as they appear in the output and match the keys in your `input` object.
764
+
765
+ #### Using the `withSudo` Helper
766
+
767
+ Since running commands with `sudo` is a very common pattern, `hostctl` provides a convenient helper function, `withSudo`, to simplify this process. It automatically creates the correct input mapping for the `sudo` password prompt.
768
+
769
+ ```typescript
770
+ import { task, type TaskContext, withSudo } from 'hostctl';
771
+
772
+ async function run(context: TaskContext): Promise<void> {
773
+ const { exec, log, getPassword } = context;
774
+
775
+ const sudoPassword = await getPassword(); // Prompts user if not already provided
776
+ if (!sudoPassword) {
777
+ throw new Error('Sudo password is required but was not provided.');
778
+ }
779
+
780
+ // Use the helper to build the input map
781
+ const input = withSudo(sudoPassword);
782
+
783
+ log(Verbosity.INFO as LogLevel, 'Running a command with sudo...');
784
+
785
+ // Pass the generated input map to the exec options
786
+ const result = await exec('sudo apt-get update', { pty: true, input });
787
+
788
+ if (result.exitCode === 0) {
789
+ log(Verbosity.INFO as LogLevel, "'apt-get update' completed successfully.");
790
+ } else {
791
+ log(Verbosity.ERROR as LogLevel, "'apt-get update' failed.");
792
+ }
793
+ }
794
+
795
+ export default task(run, 'Demonstrate using the withSudo helper');
796
+ ```
797
+
798
+ The `withSudo` helper can also be combined with other interactions:
799
+
800
+ `const input = withSudo(password, { "Do you want to continue?": "yes" });`
801
+
802
+ ### A Complete Example: Installing a Package
803
+
804
+ Here is a realistic example of a task that installs a package on a Debian-based system.
805
+
806
+ ```typescript
807
+ // src/tasks/install-nginx.ts
808
+ import { task, type TaskContext, Verbosity, type LogLevel } from 'hostctl';
809
+
810
+ export interface InstallNginxParams {
811
+ updateCache?: boolean;
812
+ }
813
+
814
+ async function run(context: TaskContext<InstallNginxParams>): Promise<void> {
815
+ const { params, log, exec } = context;
816
+
817
+ log(Verbosity.INFO as LogLevel, 'Starting Nginx installation...');
818
+
819
+ if (params.updateCache) {
820
+ log(Verbosity.DEBUG as LogLevel, 'Updating package cache...');
821
+ await exec('sudo apt-get update');
822
+ }
823
+
824
+ log(Verbosity.INFO as LogLevel, 'Installing nginx package...');
825
+ const installResult = await exec('sudo apt-get install -y nginx');
826
+
827
+ if (installResult.exitCode !== 0) {
828
+ log(Verbosity.ERROR as LogLevel, 'Failed to install Nginx.');
829
+ throw new Error(`apt-get failed with exit code ${installResult.exitCode}`);
830
+ }
831
+
832
+ log(Verbosity.INFO as LogLevel, 'Nginx installed successfully.');
833
+ }
834
+
835
+ export default task(run, 'Install Nginx on a Debian-based system');
836
+ ```
837
+
838
+ ## Roadmap: Expanding Capabilities
839
+
840
+ To further enhance `hostctl` and position it as a comprehensive Ansible alternative, the following task primitives are planned for future development. These aim to provide built-in support for common system administration and automation workflows:
841
+
842
+ 1. **File Management:**
843
+ - `file.replace` (regex substitution)
844
+ - `file.fetch` (remote → local)
845
+
846
+ 2. **Package Management:**
847
+ - `pkg.purge`
848
+ - `pkg.autoremove`
849
+
850
+ 3. **User & Group Management:**
851
+ - `user.password` (change password)
852
+
853
+ 4. **System Operations:**
854
+ - `system.timezone`
855
+ - `system.mount`
856
+
857
+ 5. **Archive Management:**
858
+ - `archive.compress`
859
+ - `archive.extract`
860
+ - `archive.unarchive` (auto-detect format)
861
+
862
+ 6. **Networking & Firewall:**
863
+ - `firewall.status`
864
+ - `firewall.add_rule`
865
+ - `firewall.remove_rule`
866
+ - `network.interfaces`
867
+ - `network.get_facts`
868
+
869
+ 7. **Templating & Control Flow:**
870
+ - Enhanced templating (diff mode, variables, idempotent)
871
+ - `retry`
872
+ - `until` (wait for condition)
873
+ - `include_tasks` (run another script)
874
+ - `wait_for`
875
+ - `assert`
876
+ - Improved looping & retry helpers
877
+
878
+ This roadmap will evolve, and contributions are welcome!
879
+
880
+ 3. **Define ResultTypes** (optional but recommended): Specify the structure of the result your task will return (e.g., `MyTaskResult`).
881
+ 4. **Implement the `run` function**: This is the core logic of your task.
882
+ 5. **Export the task**: Use `export default task(runFunction, {description: "Optional description"});`.
883
+
884
+ Here's a template:
885
+
886
+ ```typescript
887
+ // my-task.ts
888
+ import { task, type TaskContext, type IInvocation, type LogLevel } from 'hostctl'; // Adjust path if developing hostctl itself
889
+ // For users of the hostctl package, it would typically be:
890
+ // import { task, type TaskContext, type IInvocation, type LogLevel, Verbosity } from "hostctl";
891
+
892
+ // (If developing hostctl, Verbosity might be imported from a relative path like "../app")
893
+ import { Verbosity } from '../app'; // Example for internal development path
894
+
895
+ // 1. Define the structure of parameters your task expects
896
+ export type MyTaskParams = {
897
+ targetSystem: string;
898
+ enableFeature?: boolean;
899
+ };
900
+
901
+ // 2. Define the structure of the result your task will return
902
+ export interface MyTaskResult {
903
+ success: boolean;
904
+ details?: string;
905
+ }
906
+
907
+ // 3. Implement the core task logic in a 'run' function
908
+ async function run(
909
+ context: TaskContext<MyTaskParams>, // First argument is the TaskContext
910
+ ): Promise<MyTaskResult> {
911
+ // Access parameters passed to the script via context.params
912
+ const { targetSystem, enableFeature = false } = context.params;
913
+
914
+ // Use 'this' for invocation-specific operations
915
+ this.log(Verbosity.INFO as LogLevel, `Configuring ${targetSystem}...`);
916
+
917
+ try {
918
+ // Use context for runtime utilities like 'exec' or 'file' operations
919
+ const { stdout } = await context.exec(
920
+ `configure-system --target ${targetSystem} ${enableFeature ? '--enable' : ''}`,
921
+ );
922
+ this.log(Verbosity.DEBUG as LogLevel, `Configuration output: ${stdout}`);
923
+
924
+ // Example of using this.sh (often a shorthand for context.exec for simple commands)
925
+ // const { stdout: diskSpace } = await this.sh("df -h /");
926
+ // this.log(Verbosity.INFO as LogLevel, `Disk space: ${diskSpace}`);
927
+
928
+ return { success: true, details: 'System configured successfully.' };
929
+ } catch (error: any) {
930
+ this.log(Verbosity.ERROR as LogLevel, `Error during configuration: ${error.message}`);
931
+ return { success: false, details: error.message };
932
+ }
933
+ }
934
+
935
+ // 4. Export the task, optionally with a dynamic description
936
+ // The '{{ args?.join(" ") }}' uses Handlebars templating.
937
+ // 'args' needs to be available in the template context for this to work.
938
+ export default task(run, 'Configures {{ targetSystem }} with feature: {{ enableFeature }}');
939
+ ```
940
+
941
+ ### The `run` Function Signature
942
+
943
+ The `run` function is where your task's main logic resides. It must conform to the following signature:
944
+
945
+ `async function run(context: TaskContext<TParams>): Promise<TResult>`
946
+
947
+ - **`context: TaskContext<TParams>`**: This is the **first and only argument** passed to your `run` function. It's a crucial object providing access to:
948
+
949
+ - `context.params: TParams`: An object containing the parameters passed to your script from the command line (e.g., `args:key,value` or via `--params <json_string>`). `TParams` is a type interface you define to structure these expected parameters.
950
+ - `context.log`, `context.exec`: The same logging and command execution functions available via `this`.
951
+ - `context.file: FileSystemOperations`: An object with methods for file system operations (`read`, `write`, `exists`, `mkdir`, `rm`) that work on the target host (local or remote).
952
+ - `context.host?: Host`: If the task is executing on a remote host, this object provides details about that host.
953
+ - `context.cwd: string`: The current working directory of the task.
954
+
955
+ - **`Promise<TResult>`**: Your `run` function must be `async` and should return a `Promise`. The value this promise resolves to is considered the result of your task. `TResult` is a type interface you define for structuring this result.
956
+
957
+ ### Defining Parameters (`TParams`) and Results (`TResult`)
958
+
959
+ It's highly recommended to define TypeScript interfaces or types for your task's parameters (`TParams`) and its return value (`TResult`). This greatly improves code clarity, maintainability, and enables type checking.
960
+
961
+ ```typescript
962
+ // For parameters
963
+ export type MyScriptParams = {
964
+ inputFile: string;
965
+ outputDir?: string;
966
+ forceOverwrite: boolean;
967
+ };
968
+
969
+ // For results
970
+ export interface MyScriptResult {
971
+ filesProcessed: number;
972
+ errorsEncountered: number;
973
+ outputLocation?: string;
974
+ }
975
+ ```
976
+
977
+ ### Example: The `echo` Task (from `src/core/echo.ts`)
978
+
979
+ This task demonstrates the core concepts:
980
+
981
+ ```typescript
982
+ import { task, type TaskContext, type IInvocation, type LogLevel } from 'hostctl';
983
+ import { Verbosity } from 'hostctl/app'; // Path for user-facing package
984
+
985
+ // 1. Define Parameters
986
+ export type EchoParams = {
987
+ args: string[]; // Expects an array of strings named 'args'
988
+ };
989
+
990
+ // 2. Define Result
991
+ export interface EchoResult {
992
+ stdout: string; // The echoed output
993
+ }
994
+
995
+ // 3. Implement the 'run' function
996
+ async function run(context: TaskContext<EchoParams>): Promise<EchoResult> {
997
+ const { params, exec, log } = context;
998
+ // Provide a default for args in case it's not passed from the CLI
999
+ const { args = [] } = params;
1000
+
1001
+ log(Verbosity.DEBUG as LogLevel, `Echoing arguments: ${args.join(' ')}`);
1002
+
1003
+ // Use context.exec with an array of command and arguments
1004
+ // This is generally safer than manually joining strings for shell execution.
1005
+ const commandArray = ['echo', ...args];
1006
+ const { stdout } = await exec(commandArray);
1007
+
1008
+ return {
1009
+ stdout, // Return the standard output of the echo command
1010
+ };
1011
+ }
1012
+
1013
+ // 4. Export the task with a dynamic description
1014
+ // The '{{ args?.join(" ") }}' uses Handlebars templating.
1015
+ // 'args' needs to be available in the template context for this to work.
1016
+ export default task(run, "Echoes the provided arguments: {{ args?.join(' ') }}");
1017
+ ```
1018
+
1019
+ To run this echo task:
1020
+ `hostctl run path/to/echo.ts args:hello,world`
1021
+ Inside the `run` function, `context.params` would be `{ args: ["hello", "world"] }`.
1022
+
1023
+ ## TODO
1024
+
1025
+ - [ ] Further refine the script execution interface.
1026
+ - [ ] Expand documentation for script development.
1027
+ - [ ] Add more examples for common use cases.