@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.2",
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.48"
150
+ "@effect/platform-node-shared": "4.0.0-beta.65"
151
151
  },
152
152
  "engines": {
153
153
  "bun": ">=1.0.0"
@@ -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* runQueryWithOptionalTunnel(config, queryEffect);
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* runQueryWithOptionalTunnel(config, queryEffect);
646
+ const result = yield* runWithVpnPrerequisites(
647
+ config.port,
648
+ runQueryWithOptionalTunnel(config, queryEffect),
649
+ );
619
650
 
620
651
  if (result.success) {
621
652
  const descriptor =
@@ -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
- const context = yield* resolveContext(profile, k8sConfig);
176
- const fullCommand = `kubectl --context ${context} ${cmd}`;
177
-
178
- const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
179
-
180
- if (Option.isNone(resultOption)) {
181
- return yield* new K8sTimeoutError({
182
- message: `Command timed out after ${timeoutMs}ms`,
183
- command: fullCommand,
184
- timeoutMs,
185
- });
186
- }
187
-
188
- const result = resultOption.value;
189
-
190
- return {
191
- stdout: result.stdout,
192
- stderr: result.stderr,
193
- exitCode: result.exitCode,
194
- command: fullCommand,
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* (
@@ -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 stdoutChunk = yield* process.stdout.pipe(Stream.decodeText(), Stream.runCollect);
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>;