@blogic-cz/agent-tools 0.14.2 → 0.14.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
"vitest": "^4.1.4"
|
|
148
148
|
},
|
|
149
149
|
"overrides": {
|
|
150
|
-
"@effect/platform-node-shared": "4.0.0-beta.
|
|
150
|
+
"@effect/platform-node-shared": "4.0.0-beta.65"
|
|
151
151
|
},
|
|
152
152
|
"engines": {
|
|
153
153
|
"bun": ">=1.0.0"
|
package/src/db-tool/service.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { Clock, Context, Duration, Effect, Layer, Ref, Stream } from "effect";
|
|
|
3
3
|
|
|
4
4
|
import type { DbConfig, QueryResult, SchemaMode } from "./types";
|
|
5
5
|
|
|
6
|
+
import { ConfigService } from "#config";
|
|
7
|
+
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
8
|
+
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
6
9
|
import { DbConfigService, DbConfigServiceLayer, TUNNEL_CHECK_INTERVAL_MS } from "./config-service";
|
|
7
10
|
import {
|
|
8
11
|
DbConnectionError,
|
|
@@ -49,6 +52,7 @@ export class DbService extends Context.Service<
|
|
|
49
52
|
Effect.scoped(
|
|
50
53
|
Effect.gen(function* () {
|
|
51
54
|
const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
55
|
+
const agentToolsConfig = yield* ConfigService;
|
|
52
56
|
const dbConfig = yield* DbConfigService;
|
|
53
57
|
|
|
54
58
|
if (!dbConfig) {
|
|
@@ -170,6 +174,27 @@ export class DbService extends Context.Service<
|
|
|
170
174
|
}),
|
|
171
175
|
);
|
|
172
176
|
|
|
177
|
+
const runWithVpnPrerequisites = <E>(
|
|
178
|
+
port: number,
|
|
179
|
+
effect: Effect.Effect<QueryResult, E>,
|
|
180
|
+
): Effect.Effect<QueryResult, E | DbTunnelError> =>
|
|
181
|
+
runWithProfilePrerequisites(
|
|
182
|
+
agentToolsConfig ?? {},
|
|
183
|
+
dbConfig,
|
|
184
|
+
(command, _label) => executeShellCommand(command),
|
|
185
|
+
effect,
|
|
186
|
+
).pipe(
|
|
187
|
+
Effect.mapError((error) =>
|
|
188
|
+
isPrerequisiteRunError(error)
|
|
189
|
+
? new DbTunnelError({
|
|
190
|
+
message: error.message,
|
|
191
|
+
port,
|
|
192
|
+
hint: error.hint,
|
|
193
|
+
})
|
|
194
|
+
: error,
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
|
|
173
198
|
const waitForPort = (port: number, timeoutMs: number, intervalMs: number) =>
|
|
174
199
|
Effect.gen(function* () {
|
|
175
200
|
const startTime = yield* Clock.currentTimeMillis;
|
|
@@ -573,7 +598,10 @@ export class DbService extends Context.Service<
|
|
|
573
598
|
? executeMutationQuery(config, sql, password, Number(startTimeMs))
|
|
574
599
|
: executeSelectQuery(config, sql, password, Number(startTimeMs), true);
|
|
575
600
|
|
|
576
|
-
return yield*
|
|
601
|
+
return yield* runWithVpnPrerequisites(
|
|
602
|
+
config.port,
|
|
603
|
+
runQueryWithOptionalTunnel(config, queryEffect),
|
|
604
|
+
);
|
|
577
605
|
});
|
|
578
606
|
|
|
579
607
|
const executeSchemaQuery = Effect.fn("DbService.executeSchemaQuery")(function* (
|
|
@@ -615,7 +643,10 @@ export class DbService extends Context.Service<
|
|
|
615
643
|
? executeSelectQuery(config, getRelationships(), password, Number(startTimeMs))
|
|
616
644
|
: executeFullSchemaQuery(config, password, Number(startTimeMs));
|
|
617
645
|
|
|
618
|
-
const result = yield*
|
|
646
|
+
const result = yield* runWithVpnPrerequisites(
|
|
647
|
+
config.port,
|
|
648
|
+
runQueryWithOptionalTunnel(config, queryEffect),
|
|
649
|
+
);
|
|
619
650
|
|
|
620
651
|
if (result.success) {
|
|
621
652
|
const descriptor =
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
} from "./errors";
|
|
12
12
|
import { ConfigService, getToolConfig } from "#config";
|
|
13
13
|
import type { K8sConfig } from "#config";
|
|
14
|
+
import { collectProcessOutput } from "#shared/exec";
|
|
15
|
+
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
16
|
+
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
14
17
|
import { isKubectlCommandAllowed } from "./security";
|
|
15
18
|
|
|
16
19
|
export class K8sService extends Context.Service<
|
|
@@ -102,6 +105,24 @@ export class K8sService extends Context.Service<
|
|
|
102
105
|
),
|
|
103
106
|
);
|
|
104
107
|
|
|
108
|
+
const runPrerequisiteCommand = (command: ChildProcess.Command, label: string) =>
|
|
109
|
+
Effect.scoped(
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const process = yield* executor.spawn(command);
|
|
112
|
+
return yield* collectProcessOutput(process);
|
|
113
|
+
}),
|
|
114
|
+
).pipe(
|
|
115
|
+
Effect.mapError(
|
|
116
|
+
(platformError) =>
|
|
117
|
+
new K8sCommandError({
|
|
118
|
+
message: `Prerequisite command failed (${label}): ${String(platformError)}`,
|
|
119
|
+
command: label,
|
|
120
|
+
exitCode: -1,
|
|
121
|
+
stderr: String(platformError),
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
|
|
105
126
|
const resolveContext = Effect.fn("K8sService.resolveContext")(function* (
|
|
106
127
|
profile: string | undefined,
|
|
107
128
|
k8sConfig: K8sConfig,
|
|
@@ -172,27 +193,44 @@ export class K8sService extends Context.Service<
|
|
|
172
193
|
) {
|
|
173
194
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
174
195
|
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
return yield* runWithProfilePrerequisites(
|
|
197
|
+
config ?? {},
|
|
198
|
+
k8sConfig,
|
|
199
|
+
runPrerequisiteCommand,
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const context = yield* resolveContext(profile, k8sConfig);
|
|
202
|
+
const fullCommand = `kubectl --context ${context} ${cmd}`;
|
|
203
|
+
|
|
204
|
+
const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
|
|
205
|
+
|
|
206
|
+
if (Option.isNone(resultOption)) {
|
|
207
|
+
return yield* new K8sTimeoutError({
|
|
208
|
+
message: `Command timed out after ${timeoutMs}ms`,
|
|
209
|
+
command: fullCommand,
|
|
210
|
+
timeoutMs,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = resultOption.value;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
stdout: result.stdout,
|
|
218
|
+
stderr: result.stderr,
|
|
219
|
+
exitCode: result.exitCode,
|
|
220
|
+
command: fullCommand,
|
|
221
|
+
};
|
|
222
|
+
}),
|
|
223
|
+
).pipe(
|
|
224
|
+
Effect.mapError((error) =>
|
|
225
|
+
isPrerequisiteRunError(error)
|
|
226
|
+
? new K8sContextError({
|
|
227
|
+
message: error.message,
|
|
228
|
+
clusterId: k8sConfig.clusterId,
|
|
229
|
+
hint: error.hint,
|
|
230
|
+
})
|
|
231
|
+
: error,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
196
234
|
});
|
|
197
235
|
|
|
198
236
|
const runCommand = Effect.fn("K8sService.runCommand")(function* (
|
package/src/shared/exec.ts
CHANGED
|
@@ -10,6 +10,18 @@ export class ExecError extends Schema.TaggedErrorClass<ExecError>()("ExecError",
|
|
|
10
10
|
stderr: Schema.String,
|
|
11
11
|
}) {}
|
|
12
12
|
|
|
13
|
+
export const collectProcessOutput = (process: ChildProcessSpawner.ChildProcessHandle) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const stdoutChunk = yield* process.stdout.pipe(Stream.decodeText(), Stream.runCollect);
|
|
16
|
+
const stderrChunk = yield* process.stderr.pipe(Stream.decodeText(), Stream.runCollect);
|
|
17
|
+
|
|
18
|
+
const stdout = stdoutChunk.join("");
|
|
19
|
+
const stderr = stderrChunk.join("");
|
|
20
|
+
const exitCode = yield* process.exitCode;
|
|
21
|
+
|
|
22
|
+
return { stdout, stderr, exitCode };
|
|
23
|
+
});
|
|
24
|
+
|
|
13
25
|
export const execEffect = (
|
|
14
26
|
commandStr: string,
|
|
15
27
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
@@ -29,12 +41,7 @@ export const execEffect = (
|
|
|
29
41
|
|
|
30
42
|
const process = yield* executor.spawn(command);
|
|
31
43
|
|
|
32
|
-
const
|
|
33
|
-
const stderrChunk = yield* process.stderr.pipe(Stream.decodeText(), Stream.runCollect);
|
|
34
|
-
|
|
35
|
-
const stdout = stdoutChunk.join("");
|
|
36
|
-
const stderr = stderrChunk.join("");
|
|
37
|
-
const exitCode = yield* process.exitCode;
|
|
44
|
+
const { stdout, stderr, exitCode } = yield* collectProcessOutput(process);
|
|
38
45
|
|
|
39
46
|
if (exitCode !== 0) {
|
|
40
47
|
return yield* new ExecError({
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class PrerequisiteRunError extends Schema.TaggedErrorClass<PrerequisiteRunError>()(
|
|
4
|
+
"PrerequisiteRunError",
|
|
5
|
+
{
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
hint: Schema.optionalKey(Schema.String),
|
|
8
|
+
},
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
export const isPrerequisiteRunError = (error: unknown): error is PrerequisiteRunError =>
|
|
12
|
+
error instanceof PrerequisiteRunError;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Clock, Duration, Effect, Result } from "effect";
|
|
2
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
3
|
+
|
|
4
|
+
import type { AgentToolsConfig, ProfilePrerequisites } from "#config/types";
|
|
5
|
+
import type { PrerequisiteCommandRunner, ResolvedVpnDriver } from "#shared/prerequisites/types";
|
|
6
|
+
|
|
7
|
+
import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
|
|
8
|
+
import { PrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
9
|
+
import { missingVpnToolHint, resolveVpnDriverConfig } from "#shared/prerequisites/vpn";
|
|
10
|
+
|
|
11
|
+
const makeVpnCommand = (driver: ResolvedVpnDriver, action: "status" | "start" | "stop") => {
|
|
12
|
+
if (driver.type === "macos-scutil") {
|
|
13
|
+
const args =
|
|
14
|
+
action === "status"
|
|
15
|
+
? ["--nc", "status", driver.serviceName]
|
|
16
|
+
: action === "start"
|
|
17
|
+
? ["--nc", "start", driver.serviceName]
|
|
18
|
+
: ["--nc", "stop", driver.serviceName];
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
command: ChildProcess.make("scutil", args, { stdout: "pipe", stderr: "pipe" }),
|
|
22
|
+
label: ["scutil", ...args].join(" "),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (driver.type === "linux-nmcli") {
|
|
27
|
+
const args =
|
|
28
|
+
action === "status"
|
|
29
|
+
? ["-t", "-f", "NAME", "connection", "show", "--active"]
|
|
30
|
+
: action === "start"
|
|
31
|
+
? ["connection", "up", driver.connectionName]
|
|
32
|
+
: ["connection", "down", driver.connectionName];
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
command: ChildProcess.make("nmcli", args, { stdout: "pipe", stderr: "pipe" }),
|
|
36
|
+
label: ["nmcli", ...args].join(" "),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const args =
|
|
41
|
+
action === "stop"
|
|
42
|
+
? [driver.entryName, "/disconnect"]
|
|
43
|
+
: action === "start"
|
|
44
|
+
? [driver.entryName]
|
|
45
|
+
: [];
|
|
46
|
+
return {
|
|
47
|
+
command: ChildProcess.make("rasdial", args, { stdout: "pipe", stderr: "pipe" }),
|
|
48
|
+
label: ["rasdial", ...args].join(" "),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isVpnConnectedOutput = (driver: ResolvedVpnDriver, stdout: string) => {
|
|
53
|
+
if (driver.type === "macos-scutil") {
|
|
54
|
+
return stdout.includes("Connected");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (driver.type === "linux-nmcli") {
|
|
58
|
+
return stdout
|
|
59
|
+
.trim()
|
|
60
|
+
.split("\n")
|
|
61
|
+
.some((line) => line.trim() === driver.connectionName);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return stdout.includes(driver.entryName);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const isVpnConnected = <E>(driver: ResolvedVpnDriver, runCommand: PrerequisiteCommandRunner<E>) => {
|
|
68
|
+
const statusCommand = makeVpnCommand(driver, "status");
|
|
69
|
+
return runCommand(statusCommand.command, statusCommand.label).pipe(
|
|
70
|
+
Effect.result,
|
|
71
|
+
Effect.map((result) => {
|
|
72
|
+
if (Result.isFailure(result)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result.success.exitCode === 0 && isVpnConnectedOutput(driver, result.success.stdout);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const waitForVpn = <E>(
|
|
82
|
+
driver: ResolvedVpnDriver,
|
|
83
|
+
timeoutMs: number,
|
|
84
|
+
runCommand: PrerequisiteCommandRunner<E>,
|
|
85
|
+
) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const startTime = yield* Clock.currentTimeMillis;
|
|
88
|
+
const deadline = Number(startTime) + timeoutMs;
|
|
89
|
+
let result: boolean | undefined;
|
|
90
|
+
|
|
91
|
+
yield* Effect.whileLoop({
|
|
92
|
+
while: () => result === undefined,
|
|
93
|
+
body: () =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
if (yield* isVpnConnected(driver, runCommand)) {
|
|
96
|
+
result = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const now = yield* Clock.currentTimeMillis;
|
|
101
|
+
if (Number(now) >= deadline) {
|
|
102
|
+
result = false;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
yield* Effect.sleep(Duration.millis(500));
|
|
107
|
+
}),
|
|
108
|
+
step: () => undefined,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return result === true;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
115
|
+
config: AgentToolsConfig,
|
|
116
|
+
profile: ProfilePrerequisites,
|
|
117
|
+
runCommand: PrerequisiteCommandRunner<CommandError>,
|
|
118
|
+
effect: Effect.Effect<A, E, never>,
|
|
119
|
+
): Effect.Effect<A, E | PrerequisiteRunError, never> => {
|
|
120
|
+
const prerequisites = normalizeProfilePrerequisites(profile);
|
|
121
|
+
const vpnPrerequisites = prerequisites.filter((prerequisite) => prerequisite.type === "vpn");
|
|
122
|
+
|
|
123
|
+
if (vpnPrerequisites.length === 0) {
|
|
124
|
+
return effect;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
const startedDrivers: Array<{ driver: ResolvedVpnDriver; cooldownMs: number }> = [];
|
|
129
|
+
|
|
130
|
+
for (const prerequisite of vpnPrerequisites) {
|
|
131
|
+
const vpnConfig = config.vpns?.[prerequisite.key];
|
|
132
|
+
if (!vpnConfig) {
|
|
133
|
+
return yield* new PrerequisiteRunError({
|
|
134
|
+
message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
|
|
135
|
+
hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const driverResolution = resolveVpnDriverConfig(vpnConfig);
|
|
140
|
+
if (!driverResolution.success) {
|
|
141
|
+
return yield* new PrerequisiteRunError({
|
|
142
|
+
message: driverResolution.error,
|
|
143
|
+
hint: driverResolution.hint,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const driver = driverResolution.driver;
|
|
148
|
+
const wasConnected = yield* isVpnConnected(driver, runCommand);
|
|
149
|
+
if (wasConnected) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const startCommand = makeVpnCommand(driver, "start");
|
|
154
|
+
const startResult = yield* runCommand(startCommand.command, startCommand.label).pipe(
|
|
155
|
+
Effect.mapError(
|
|
156
|
+
() =>
|
|
157
|
+
new PrerequisiteRunError({
|
|
158
|
+
message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
159
|
+
hint: missingVpnToolHint(driver),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
if (startResult.exitCode !== 0) {
|
|
165
|
+
const stderr = startResult.stderr.trim();
|
|
166
|
+
return yield* new PrerequisiteRunError({
|
|
167
|
+
message:
|
|
168
|
+
stderr !== "" ? stderr : `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
169
|
+
hint: missingVpnToolHint(driver),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const ready = yield* waitForVpn(driver, vpnConfig.connectTimeoutMs ?? 30000, runCommand);
|
|
174
|
+
if (!ready) {
|
|
175
|
+
return yield* new PrerequisiteRunError({
|
|
176
|
+
message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
|
|
177
|
+
hint: missingVpnToolHint(driver),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cleanup = prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
|
|
182
|
+
if (cleanup === "stop-if-started") {
|
|
183
|
+
startedDrivers.push({ driver, cooldownMs: vpnConfig.cooldownMs ?? 0 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const cleanup = Effect.gen(function* () {
|
|
188
|
+
for (const started of startedDrivers.toReversed()) {
|
|
189
|
+
if (started.cooldownMs > 0) {
|
|
190
|
+
yield* Effect.sleep(Duration.millis(started.cooldownMs));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const stopCommand = makeVpnCommand(started.driver, "stop");
|
|
194
|
+
yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return yield* effect.pipe(Effect.ensuring(cleanup));
|
|
199
|
+
});
|
|
200
|
+
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { ChildProcess } from "effect/unstable/process";
|
|
3
|
+
|
|
1
4
|
import type {
|
|
2
5
|
LinuxNmcliVpnDriverConfig,
|
|
3
6
|
MacosScutilVpnDriverConfig,
|
|
@@ -19,3 +22,14 @@ export type ResolvedVpnDriver =
|
|
|
19
22
|
export type VpnDriverResolution =
|
|
20
23
|
| { success: true; driver: ResolvedVpnDriver }
|
|
21
24
|
| { success: false; error: string; hint: string };
|
|
25
|
+
|
|
26
|
+
export type PrerequisiteCommandResult = {
|
|
27
|
+
readonly stdout: string;
|
|
28
|
+
readonly stderr: string;
|
|
29
|
+
readonly exitCode: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PrerequisiteCommandRunner<E> = (
|
|
33
|
+
command: ChildProcess.Command,
|
|
34
|
+
label: string,
|
|
35
|
+
) => Effect.Effect<PrerequisiteCommandResult, E, never>;
|