@blogic-cz/agent-tools 0.14.1 → 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/README.md +3 -3
- package/package.json +5 -5
- package/schemas/agent-tools.schema.json +14 -3
- package/src/config/loader.ts +2 -0
- package/src/config/types.ts +1 -1
- package/src/db-tool/service.ts +33 -2
- package/src/k8s-tool/service.ts +59 -21
- package/src/shared/exec.ts +13 -6
- package/src/shared/prerequisites/errors.ts +12 -0
- package/src/shared/prerequisites/runtime.ts +200 -0
- package/src/shared/prerequisites/types.ts +14 -0
package/README.md
CHANGED
|
@@ -174,17 +174,17 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
174
174
|
dbPath: "~/.agent-tools/audit.sqlite",
|
|
175
175
|
},
|
|
176
176
|
vpns: {
|
|
177
|
-
|
|
177
|
+
exampleVpn: {
|
|
178
178
|
// auto defaults to true:
|
|
179
179
|
// darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
|
|
180
|
-
name: "
|
|
180
|
+
name: "ExampleVPN",
|
|
181
181
|
},
|
|
182
182
|
},
|
|
183
183
|
kubernetes: {
|
|
184
184
|
default: {
|
|
185
185
|
clusterId: "your-cluster-id",
|
|
186
186
|
namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
|
|
187
|
-
prerequisites: [{ type: "vpn", key: "
|
|
187
|
+
prerequisites: [{ type: "vpn", key: "exampleVpn" }],
|
|
188
188
|
// Prerequisites are currently decoded and validated as config metadata;
|
|
189
189
|
// automatic VPN connect/disconnect execution is planned for a follow-up release.
|
|
190
190
|
},
|
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",
|
|
@@ -133,13 +133,13 @@
|
|
|
133
133
|
"test": "vitest run"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@effect/platform-bun": "4.0.0-beta.
|
|
136
|
+
"@effect/platform-bun": "4.0.0-beta.65",
|
|
137
137
|
"@toon-format/toon": "2.1.0",
|
|
138
|
-
"effect": "4.0.0-beta.
|
|
138
|
+
"effect": "4.0.0-beta.65"
|
|
139
139
|
},
|
|
140
140
|
"devDependencies": {
|
|
141
141
|
"@effect/language-service": "0.85.1",
|
|
142
|
-
"@effect/vitest": "4.0.0-beta.
|
|
142
|
+
"@effect/vitest": "4.0.0-beta.65",
|
|
143
143
|
"@types/bun": "1.3.12",
|
|
144
144
|
"oxfmt": "0.44.0",
|
|
145
145
|
"oxlint": "1.59.0",
|
|
@@ -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"
|
|
@@ -224,6 +224,17 @@
|
|
|
224
224
|
"remotePort": {
|
|
225
225
|
"description": "Optional remote database port used by the tunnel.",
|
|
226
226
|
"type": "number"
|
|
227
|
+
},
|
|
228
|
+
"prerequisites": {
|
|
229
|
+
"description": "Ordered prerequisite references required before remote database access.",
|
|
230
|
+
"type": "array",
|
|
231
|
+
"items": {
|
|
232
|
+
"$ref": "#/definitions/Prerequisite"
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"vpn": {
|
|
236
|
+
"type": "string",
|
|
237
|
+
"description": "Convenience sugar for a VPN prerequisite key. Runtime normalizes this to prerequisites."
|
|
227
238
|
}
|
|
228
239
|
},
|
|
229
240
|
"required": ["environments"]
|
|
@@ -529,7 +540,7 @@
|
|
|
529
540
|
"namespaces": {
|
|
530
541
|
"test": "my-app-test",
|
|
531
542
|
"prod": "my-app-prod",
|
|
532
|
-
"system": "
|
|
543
|
+
"system": "system"
|
|
533
544
|
},
|
|
534
545
|
"timeoutMs": 60000
|
|
535
546
|
}
|
|
@@ -553,8 +564,8 @@
|
|
|
553
564
|
}
|
|
554
565
|
},
|
|
555
566
|
"kubectl": {
|
|
556
|
-
"context": "
|
|
557
|
-
"namespace": "
|
|
567
|
+
"context": "example-cluster",
|
|
568
|
+
"namespace": "system"
|
|
558
569
|
},
|
|
559
570
|
"tunnelTimeoutMs": 5000,
|
|
560
571
|
"remotePort": 5432
|
package/src/config/loader.ts
CHANGED
|
@@ -100,6 +100,8 @@ const DatabaseConfigSchema = Schema.Struct({
|
|
|
100
100
|
),
|
|
101
101
|
tunnelTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
102
102
|
remotePort: Schema.optionalKey(Schema.Number),
|
|
103
|
+
prerequisites: Schema.optionalKey(PrerequisitesSchema),
|
|
104
|
+
vpn: Schema.optionalKey(Schema.String),
|
|
103
105
|
});
|
|
104
106
|
|
|
105
107
|
const LogsConfigSchema = Schema.Struct({
|
package/src/config/types.ts
CHANGED
|
@@ -78,7 +78,7 @@ export type DbEnvConfig = {
|
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
/** Database profile configuration */
|
|
81
|
-
export type DatabaseConfig = {
|
|
81
|
+
export type DatabaseConfig = ProfilePrerequisites & {
|
|
82
82
|
/** Named database environments, e.g. { local: {...}, test: {...}, prod: {...} } */
|
|
83
83
|
environments: Record<string, DbEnvConfig>;
|
|
84
84
|
kubectl?: {
|
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>;
|