@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
package/README.md ADDED
@@ -0,0 +1 @@
1
+ Main sources of `katmer` cli and runtime are located under this folder.
package/cli/katmer.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander"
4
+ import { version, description } from "../package.json"
5
+ import run from "./run"
6
+
7
+ const cli = new Command()
8
+ cli
9
+ .name("katmer")
10
+ .description(description)
11
+ .option(
12
+ "-t, --target [files...]",
13
+ "Path to config file",
14
+ "/etc/katmer/config.yaml"
15
+ )
16
+ .option("--cwd [dir]", "Override working directory")
17
+ .version(version, "-v, --version")
18
+ .helpOption("--help")
19
+
20
+ run(cli)
21
+
22
+ try {
23
+ await cli.parseAsync(process.argv)
24
+ } catch (e) {
25
+ console.error(e)
26
+ console.error(e.message || e)
27
+ process.exit(1)
28
+ }
package/cli/run.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { Command } from "commander"
2
+ import { KatmerCore, type KatmerInitOptions } from "../lib/katmer"
3
+
4
+ export default function (cli: Command) {
5
+ const command = new Command("run")
6
+ .description("Execute katmer task file")
7
+ .argument("<file>", "The file to run")
8
+ .action(async (file, opts) => {
9
+ const options = cli.opts<KatmerInitOptions>()
10
+ await using instance = new KatmerCore(options)
11
+ await instance.init()
12
+ await instance.run(file)
13
+ })
14
+ cli.addCommand(command)
15
+ return cli
16
+ }
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { KatmerCore } from "./lib/katmer"
2
+
3
+ export type { Katmer } from "./lib/katmer"
4
+
5
+ export { KatmerCore }
package/lib/config.ts ADDED
@@ -0,0 +1,82 @@
1
+ import configSchema from "./schemas/katmer_config.schema.json" with { type: "json" }
2
+
3
+ import Ajv from "ajv/dist/2020"
4
+ import ajvErrors from "ajv-errors"
5
+ import { toMerged } from "es-toolkit"
6
+
7
+ import { parseKatmerFile, readKatmerFile } from "./utils/file.utils"
8
+ import { wrapInArray } from "./utils/json.utils"
9
+ import { HttpModule } from "./modules/http/http.local.module"
10
+ import { LocalProvider } from "./providers/local.provider"
11
+ import { normalizeAjvError } from "./utils/ajv.utils"
12
+ import type { KatmerConfig } from "./interfaces/config.interface"
13
+
14
+ const ajv = ajvErrors(
15
+ new Ajv({
16
+ allErrors: true,
17
+ allowMatchingProperties: true,
18
+ allowUnionTypes: true
19
+ }),
20
+ {
21
+ singleError: false
22
+ }
23
+ ).addSchema(configSchema)
24
+
25
+ export const KatmerConfigLoader = {
26
+ async load(
27
+ target: string | object | (string | object)[],
28
+ opts?: {
29
+ cwd?: string
30
+ }
31
+ ): Promise<KatmerConfig> {
32
+ const configTargets = wrapInArray(target)
33
+ const cwd = opts?.cwd || process.cwd()
34
+
35
+ let config = {} as KatmerConfig
36
+
37
+ for (const configTarget of configTargets) {
38
+ let loadedConfig: Record<string, any> = {}
39
+ if (typeof configTarget === "string") {
40
+ const parsed = await readKatmerFile(configTarget, {
41
+ cwd,
42
+ process: false,
43
+ errorMessage: `Failed to load config file from: ${configTarget}`
44
+ })
45
+ loadedConfig = this.validate(parsed, configTarget)
46
+ } else {
47
+ const fetch = new HttpModule(configTarget as any, new LocalProvider({}))
48
+ const { body, url } = await fetch.execute({} as any)
49
+
50
+ const filename = `${url.origin}${url.pathname}`
51
+ loadedConfig = this.validate(
52
+ await parseKatmerFile(filename, body),
53
+ filename
54
+ )
55
+ }
56
+
57
+ if (loadedConfig.include) {
58
+ loadedConfig = toMerged(
59
+ loadedConfig,
60
+ await KatmerConfigLoader.load(loadedConfig.include, opts)
61
+ )
62
+ }
63
+ loadedConfig.include = undefined
64
+ config = toMerged(config, loadedConfig)
65
+ }
66
+ config.cwd = opts?.cwd || process.cwd()
67
+ return config
68
+ },
69
+ validate(obj: any, filename?: string) {
70
+ const configValidator = ajv.getSchema(
71
+ "https://katmer.dev/schemas/katmer-config.schema.json"
72
+ )!
73
+ configValidator(obj)
74
+ if (configValidator.errors) {
75
+ const err = configValidator.errors[0]
76
+ throw new Error(
77
+ `Invalid configuration${filename ? ` [${filename}]` : ""}: ${normalizeAjvError(err)} at path: ${err.instancePath}`
78
+ )
79
+ }
80
+ return obj
81
+ }
82
+ }
@@ -0,0 +1,113 @@
1
+ import pino from "pino"
2
+
3
+ export interface KatmerCLIOptions {
4
+ cwd?: string
5
+ target: string[]
6
+ }
7
+ // Connection variants
8
+ export interface SSHConfig {
9
+ connection: "ssh"
10
+ hostname: string
11
+ port?: number
12
+ username?: string
13
+ password?: string
14
+ private_key?: string
15
+ private_key_password?: string
16
+ [k: string]: unknown
17
+ }
18
+
19
+ export interface LocalConfig {
20
+ connection: "local"
21
+ [k: string]: unknown
22
+ }
23
+
24
+ export type KatmerHostInput = SSHConfig | LocalConfig
25
+
26
+ export type KatmerHostResolved = (SSHConfig | LocalConfig) & {
27
+ name: string
28
+ variables?: Record<string, unknown>
29
+ environment?: Record<string, string>
30
+ }
31
+
32
+ // Reserved labels
33
+ export type KatmerReservedKey =
34
+ | "all"
35
+ | "children"
36
+ | "settings"
37
+ | "hosts"
38
+ | "variables"
39
+
40
+ // Group settings (anything mergeable; no required `connection`)
41
+ export type KatmerGroupSettings = {
42
+ [k: string]: unknown
43
+ } & Partial<
44
+ Omit<SSHConfig, "connection" | "hostname"> & Omit<LocalConfig, "connection">
45
+ >
46
+
47
+ export type KatmerGroupVariables = Record<string, unknown>
48
+ export type KatmerGroupEnvironment = Record<string, unknown>
49
+
50
+ // Strict host map for the **root** form
51
+ export type KatmerHostsRoot = Record<string, KatmerHostInput>
52
+
53
+ // Shorthand-friendly host map for **groups** (allows `{}` etc.)
54
+ export type KatmerHostShorthand = Partial<KatmerHostInput>
55
+ export type KatmerHostsInGroup = Record<string, KatmerHostShorthand>
56
+
57
+ // Children reference map
58
+ export type KatmerChildren = Record<string, {} | undefined>
59
+
60
+ // A single group
61
+ export interface KatmerGroup {
62
+ children?: KatmerChildren
63
+ hosts?: KatmerHostsInGroup
64
+ settings?: KatmerGroupSettings // ← accepts {}
65
+ variables?: KatmerGroupVariables
66
+ environment?: KatmerGroupEnvironment
67
+ }
68
+
69
+ // Root “hosts/settings” form (implicit 'ungrouped')
70
+ export interface KatmerTargetsRootForm {
71
+ hosts: KatmerHostsRoot // ← strict here
72
+ settings?: KatmerGroupSettings
73
+ variables?: KatmerGroupVariables
74
+ environment?: KatmerGroupEnvironment
75
+ }
76
+
77
+ // Grouped form (forbid reserved top-level keys here)
78
+ export type KatmerTargetsGroupedForm = Record<string, KatmerGroup> & {
79
+ all?: never
80
+ children?: never
81
+ hosts?: never
82
+ settings?: never
83
+ variables?: never
84
+ }
85
+
86
+ // Top-level targets
87
+ export type KatmerTargets = KatmerTargetsRootForm | KatmerTargetsGroupedForm
88
+
89
+ export interface KatmerConfig {
90
+ cwd?: string
91
+ logging?: {
92
+ dir?: string
93
+ level?: "trace" | "debug" | "info" | "warn" | "error" | "silent"
94
+ }
95
+ targets: KatmerTargets
96
+ }
97
+
98
+ // Optional: normalizer output helpers
99
+ export interface KatmerNormalizedTargets {
100
+ groups: Map<string, Set<string>>
101
+ hosts: Map<string, KatmerHostResolved>
102
+ allNames: Set<string>
103
+ }
104
+
105
+ export interface StandardLogger {
106
+ trace: pino.LogFn
107
+ debug: pino.LogFn
108
+ info: pino.LogFn
109
+ warn: pino.LogFn
110
+ error: pino.LogFn
111
+ fatal: pino.LogFn
112
+ child: (bindings: Record<string, unknown>) => StandardLogger
113
+ }
@@ -0,0 +1,13 @@
1
+ export type TwigExpression = `{{${string}}}`
2
+ // common interface so AptRepositoryModule does not depend on SSH
3
+ export interface Executor {
4
+ run(
5
+ command: string,
6
+ options?: {
7
+ cwd?: string
8
+ encoding?: BufferEncoding
9
+ onStdout?: (line: string) => void
10
+ onStderr?: (line: string) => void
11
+ }
12
+ ): Promise<{ stdout: string; stderr: string; code: number }>
13
+ }
@@ -0,0 +1,170 @@
1
+ import type { OsArch, OsFamily } from "./provider.interface"
2
+
3
+ export type PackageManager =
4
+ | "apt"
5
+ | "dnf"
6
+ | "yum"
7
+ | "zypper"
8
+ | "apk"
9
+ | "pacman"
10
+ | "brew"
11
+ | "port"
12
+ | "choco"
13
+ | "winget"
14
+ | "unknown"
15
+
16
+ export interface PackageConstraint {
17
+ /** Package name as seen by the package manager(s). */
18
+ name: string
19
+ /** Exact version (distro version string is OK), e.g. "1.5.2-1ubuntu1". */
20
+ version?: string
21
+ /**
22
+ * Semver-like or comparator range, e.g. ">=1.5.0 <2".
23
+ * If semver is not available, a simple comparator fallback is used.
24
+ */
25
+ range?: string
26
+ /** One or more preferred package managers; omit to auto-detect. */
27
+ manager?: PackageManager | PackageManager[]
28
+ /**
29
+ * Alternatives: any of these packages satisfying the constraints is acceptable.
30
+ * Useful for cross-distro names (e.g., cron | cronie | dcron).
31
+ */
32
+ alternatives?: PackageConstraint[]
33
+ /**
34
+ * Optional custom test that returns 0 if installed and prints the version.
35
+ * Example: `dpkg-query -W -f='${Version}' cron`
36
+ */
37
+ testCmd?: string
38
+ /** When using testCmd, regex to extract the version from stdout (first capture group). */
39
+ versionRegex?: string
40
+ }
41
+
42
+ export interface BinaryConstraint {
43
+ /** Command to locate (e.g., "crontab", "bash", "powershell"). */
44
+ cmd: string
45
+ /** Optional args to run for version probe; default tries "--version" or "-V". */
46
+ args?: string[]
47
+ /** Regex to parse a version string from stdout/stderr (first capture group used). */
48
+ versionRegex?: string | RegExp
49
+ /** Version range to satisfy (same rules as PackageConstraint.range). */
50
+ range?: string
51
+ /** Alternatives: if any child passes, this binary constraint is satisfied. */
52
+ or?: BinaryConstraint[]
53
+ }
54
+
55
+ export interface ModulePlatformConstraint {
56
+ /** Supported architectures; default = ["any"]. */
57
+ arch?: OsArch[]
58
+ /** Family-level packages (before per-distro overrides). */
59
+ packages?: Array<PackageConstraint | string>
60
+ /** Required binaries/shells present in PATH. */
61
+ binaries?: BinaryConstraint[]
62
+ /** Require root (POSIX) or Administrator (Windows). */
63
+ requireRoot?: boolean
64
+ /** Optional minimal kernel version (POSIX) or OS version (Windows/macOS). */
65
+ minKernel?: string // e.g., ">=4.15"
66
+ minOsVersion?: string // e.g., ">=10.15" for macOS, ">=10.0" for Windows
67
+ /**
68
+ * Per-distro overrides/extensions. Keys are normalized IDs like:
69
+ * "debian", "ubuntu", "rhel", "centos", "rocky", "fedora", "alpine",
70
+ * "arch", "opensuse", "sles", "amzn", "amazon", or "any".
71
+ */
72
+ distro?: {
73
+ [distroId: string]: true | false | Omit<ModulePlatformConstraint, "distro">
74
+ }
75
+ }
76
+
77
+ /**
78
+ * New constraints structure:
79
+ * - platform is a map: any|linux|darwin|windows|freebsd|... → true|false|ModulePlatformConstraint
80
+ * - Each platform may also have distro overrides.
81
+ */
82
+ export interface ModuleConstraints {
83
+ platform?: {
84
+ [family in OsFamily | "any" | "local"]?:
85
+ | true
86
+ | false
87
+ | ModulePlatformConstraint
88
+ }
89
+ }
90
+
91
+ export type ModulePlatformConstraintLegacy = never // (kept only if you referenced it elsewhere)
92
+
93
+ export interface ModuleOptionsObject {
94
+ [key: string]: any // allow provider-specific options
95
+ }
96
+ export type ModuleOptions = any
97
+
98
+ /**
99
+ * Standard result shape returned by modules.
100
+ *
101
+ * @remarks
102
+ * - `changed` indicates whether the module made any modifications on the target.
103
+ * - `failed` flags an error condition; when `true`, execution is considered unsuccessful.
104
+ * - `stdout`/`stderr` carry raw process output when applicable.
105
+ * - Timing fields (`start`, `end`, `delta`) are best-effort and ISO8601 for start/end.
106
+ * - `attempts`/`retries` are used by controls like `until` to report how many times an action ran.
107
+ * - Extra module-specific keys are allowed via the index signature.
108
+ *
109
+ * @example
110
+ * ```yaml
111
+ * - name: fetch metadata
112
+ * register: meta
113
+ * http:
114
+ * url: "https://example.com/meta"
115
+ * fail_on_http_error: false
116
+ *
117
+ * - name: inspect result
118
+ * debug:
119
+ * vars:
120
+ * ok: "{{ not meta.failed }}"
121
+ * changed: "{{ meta.changed }}"
122
+ * status: "{{ meta.status }}"
123
+ * took: "{{ meta.delta }}"
124
+ * ```
125
+ *
126
+ * @public
127
+ */
128
+ export interface ModuleCommonReturn {
129
+ /** Whether this module changed anything on the target. */
130
+ changed?: boolean
131
+
132
+ /** Whether the module failed (non-zero exit code, validation failure, etc.). */
133
+ failed?: boolean
134
+
135
+ /** Human-readable message describing the outcome or error. */
136
+ msg?: string
137
+
138
+ /** Captured standard output, if available. */
139
+ stdout?: string
140
+
141
+ /** Captured standard error, if available. */
142
+ stderr?: string
143
+
144
+ /** ISO8601 timestamp when execution started (best-effort). */
145
+ start?: string
146
+
147
+ /** ISO8601 timestamp when execution ended (best-effort). */
148
+ end?: string
149
+
150
+ /** Duration string, e.g., "0:00:00.123" (best-effort). */
151
+ delta?: string
152
+
153
+ /**
154
+ * Number of attempts performed so far (e.g., by `until`).
155
+ * Typically increments from 1 upwards.
156
+ */
157
+ attempts?: number
158
+
159
+ /**
160
+ * Maximum retries allowed for this operation (e.g., by `until`).
161
+ * This reflects the configured ceiling, not the attempts taken.
162
+ */
163
+ retries?: number
164
+
165
+ /**
166
+ * Additional module-specific data.
167
+ * Modules may extend the result with fields such as `status`, `url`, `dest`, etc.
168
+ */
169
+ [key: string]: unknown
170
+ }
@@ -0,0 +1,214 @@
1
+ import type { ProviderResponse } from "../providers/provider_response"
2
+ import type { StandardLogger } from "./config.interface"
3
+
4
+ export type OsFamily =
5
+ | "any"
6
+ | "linux"
7
+ | "darwin"
8
+ | "windows"
9
+ | "freebsd"
10
+ | "openbsd"
11
+ | "netbsd"
12
+ | "aix"
13
+ | "solaris"
14
+ | "unknown"
15
+
16
+ export type OsArch =
17
+ | "x86_64"
18
+ | "arm64"
19
+ | "armv7"
20
+ | "armv6"
21
+ | "i386"
22
+ | "ppc64le"
23
+ | "s390x"
24
+ | "riscv64"
25
+ | "loongarch64"
26
+ | "any"
27
+ | "unknown"
28
+
29
+ // Added powershell/cmd; keep "none" for raw passthrough
30
+ export type SupportedShell =
31
+ | "bash"
32
+ | "sh"
33
+ | "zsh"
34
+ | "dash"
35
+ | "ksh"
36
+ | "mksh"
37
+ | "fish"
38
+ | "powershell"
39
+ | "cmd"
40
+ | "none"
41
+ export interface ProviderOptions {
42
+ name?: string
43
+ shell?: SupportedShell
44
+ timeout?: number
45
+ retries?: number
46
+ [key: string]: any
47
+ }
48
+
49
+ export interface OsInfo {
50
+ family: OsFamily
51
+ arch: OsArch
52
+ kernel?: string
53
+ distroId?: string
54
+ versionId?: string
55
+ prettyName?: string
56
+ source: "posix" | "powershell" | "unknown"
57
+ }
58
+
59
+ /**
60
+ * Abstract base class for all Katmer providers.
61
+ * Providers define how tasks are executed (SSH, Local, AWS SSM, GCP, etc.)
62
+ */
63
+ export abstract class KatmerProvider<
64
+ TOptions extends ProviderOptions = ProviderOptions
65
+ > {
66
+ static readonly name: string
67
+ type: string
68
+
69
+ defaultShell: SupportedShell = "sh"
70
+ os: OsInfo = {
71
+ family: "unknown",
72
+ arch: "unknown",
73
+ source: "unknown"
74
+ }
75
+ logger!: StandardLogger
76
+ options: TOptions
77
+
78
+ connected = false
79
+ initialized = false
80
+
81
+ variables: Record<string, any> = {}
82
+ environment: Record<string, string> = {}
83
+
84
+ constructor(options: TOptions) {
85
+ this.options = options
86
+ this.type = this.constructor.name
87
+ }
88
+
89
+ /**
90
+ * Validate configuration before use (e.g., check required fields).
91
+ */
92
+ abstract check(): Promise<void>
93
+
94
+ /**
95
+ * Initialize resources (e.g., allocate clients, prepare temp dirs).
96
+ */
97
+ abstract initialize(): Promise<void>
98
+
99
+ /**
100
+ * Establish connection/session.
101
+ */
102
+ abstract connect(): Promise<void>
103
+
104
+ /**
105
+ * Execute a command within this provider's context.
106
+ */
107
+ abstract executor(
108
+ options?: Record<string, any>
109
+ ): (
110
+ command: string,
111
+ options?: Record<string, any>
112
+ ) => Promise<ProviderResponse>
113
+
114
+ /**
115
+ * Tear down connection/session (but keep reusable state).
116
+ */
117
+ abstract destroy(): Promise<void>
118
+
119
+ /**
120
+ * Cleanup all allocated resources (irreversible).
121
+ */
122
+ abstract cleanup(): Promise<void>
123
+
124
+ /** Probe remote OS/arch as soon as we connect; called automatically on `ensureReady()` */
125
+ abstract getOsInfo(): Promise<OsInfo>
126
+
127
+ /** Pick the best shell based on OS and availability; sets `this.options.shell`. */
128
+ async decideDefaultShell(): Promise<SupportedShell> {
129
+ const execRaw = this.executor({ shell: "none", timeout: 4000 })
130
+
131
+ // Windows → prefer PowerShell, fallback to cmd
132
+ if (this.os.family === "windows") {
133
+ try {
134
+ const r = await execRaw(
135
+ `powershell -NoProfile -NonInteractive -Command "$PSVersionTable.PSVersion.Major"`
136
+ )
137
+ if (r.code === 0) {
138
+ this.defaultShell = "powershell"
139
+ return "powershell"
140
+ }
141
+ } catch {}
142
+ this.defaultShell = "cmd"
143
+ return "cmd"
144
+ }
145
+
146
+ // POSIX → choose first available, prefer bash/zsh
147
+ const probe =
148
+ 'for s in bash zsh ksh mksh dash sh fish; do command -v "$s" >/dev/null 2>&1 && { echo "$s"; exit 0; }; done; echo sh'
149
+ let chosen = "sh"
150
+ try {
151
+ const r = await execRaw(`sh -c '${probe}'`)
152
+ if (r.code === 0 && r.stdout?.trim()) chosen = r.stdout.trim()
153
+ } catch {
154
+ // fallback to bash if sh probing failed
155
+ try {
156
+ const r2 = await execRaw(`bash -lc '${probe}'`)
157
+ if (r2.code === 0 && r2.stdout?.trim()) chosen = r2.stdout.trim()
158
+ } catch {}
159
+ }
160
+
161
+ // normalize to SupportedShell
162
+ const asShell =
163
+ (["bash", "zsh", "ksh", "mksh", "dash", "sh", "fish"].find(
164
+ (s) => s === chosen
165
+ ) as SupportedShell) || "sh"
166
+
167
+ this.defaultShell = asShell
168
+ return asShell
169
+ }
170
+
171
+ /**
172
+ * Helper to ensure full lifecycle (for convenience in orchestrators).
173
+ */
174
+ async ensureReady(): Promise<this> {
175
+ if (!this.initialized) {
176
+ await this.check()
177
+ await this.initialize()
178
+ this.initialized = true
179
+ }
180
+ if (!this.connected) {
181
+ await this.connect()
182
+
183
+ this.os = await this.getOsInfo()
184
+ await this.decideDefaultShell()
185
+
186
+ this.connected = true
187
+ }
188
+ return this
189
+ }
190
+
191
+ /**
192
+ * Safe shutdown wrapper that handles errors gracefully.
193
+ */
194
+ async safeShutdown(): Promise<void> {
195
+ try {
196
+ await this.destroy()
197
+ this.connected = false
198
+ } catch (err) {
199
+ console.warn(`[Provider:${this.options.name}] destroy() failed:`, err)
200
+ }
201
+
202
+ try {
203
+ await this.cleanup()
204
+ this.initialized = false
205
+ } catch (err) {
206
+ console.warn(`[Provider:${this.options.name}] cleanup() failed:`, err)
207
+ }
208
+ this.logger.trace(`Disconnected from provider: ${this.options.name}`)
209
+ }
210
+
211
+ async [Symbol.asyncDispose]() {
212
+ await this.safeShutdown()
213
+ }
214
+ }