@blogic-cz/agent-tools 0.14.2 → 0.14.4

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.4",
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,28 @@ 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
+ { tryWithoutPrerequisites: true },
187
+ ).pipe(
188
+ Effect.mapError((error) =>
189
+ isPrerequisiteRunError(error)
190
+ ? new DbTunnelError({
191
+ message: error.message,
192
+ port,
193
+ hint: error.hint,
194
+ })
195
+ : error,
196
+ ),
197
+ );
198
+
173
199
  const waitForPort = (port: number, timeoutMs: number, intervalMs: number) =>
174
200
  Effect.gen(function* () {
175
201
  const startTime = yield* Clock.currentTimeMillis;
@@ -573,7 +599,10 @@ export class DbService extends Context.Service<
573
599
  ? executeMutationQuery(config, sql, password, Number(startTimeMs))
574
600
  : executeSelectQuery(config, sql, password, Number(startTimeMs), true);
575
601
 
576
- return yield* runQueryWithOptionalTunnel(config, queryEffect);
602
+ return yield* runWithVpnPrerequisites(
603
+ config.port,
604
+ runQueryWithOptionalTunnel(config, queryEffect),
605
+ );
577
606
  });
578
607
 
579
608
  const executeSchemaQuery = Effect.fn("DbService.executeSchemaQuery")(function* (
@@ -615,7 +644,10 @@ export class DbService extends Context.Service<
615
644
  ? executeSelectQuery(config, getRelationships(), password, Number(startTimeMs))
616
645
  : executeFullSchemaQuery(config, password, Number(startTimeMs));
617
646
 
618
- const result = yield* runQueryWithOptionalTunnel(config, queryEffect);
647
+ const result = yield* runWithVpnPrerequisites(
648
+ config.port,
649
+ runQueryWithOptionalTunnel(config, queryEffect),
650
+ );
619
651
 
620
652
  if (result.success) {
621
653
  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,45 @@ 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
+ { tryWithoutPrerequisites: true },
224
+ ).pipe(
225
+ Effect.mapError((error) =>
226
+ isPrerequisiteRunError(error)
227
+ ? new K8sContextError({
228
+ message: error.message,
229
+ clusterId: k8sConfig.clusterId,
230
+ hint: error.hint,
231
+ })
232
+ : error,
233
+ ),
234
+ );
196
235
  });
197
236
 
198
237
  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,208 @@
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
+ options?: { tryWithoutPrerequisites?: boolean },
120
+ ): Effect.Effect<A, E | PrerequisiteRunError, never> => {
121
+ const prerequisites = normalizeProfilePrerequisites(profile);
122
+ const vpnPrerequisites = prerequisites.filter((prerequisite) => prerequisite.type === "vpn");
123
+
124
+ if (vpnPrerequisites.length === 0) {
125
+ return effect;
126
+ }
127
+
128
+ return Effect.gen(function* () {
129
+ if (options?.tryWithoutPrerequisites) {
130
+ const directResult = yield* effect.pipe(Effect.result);
131
+ if (Result.isSuccess(directResult)) {
132
+ return directResult.success;
133
+ }
134
+ }
135
+
136
+ const startedDrivers: Array<{ driver: ResolvedVpnDriver; cooldownMs: number }> = [];
137
+
138
+ for (const prerequisite of vpnPrerequisites) {
139
+ const vpnConfig = config.vpns?.[prerequisite.key];
140
+ if (!vpnConfig) {
141
+ return yield* new PrerequisiteRunError({
142
+ message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
143
+ hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
144
+ });
145
+ }
146
+
147
+ const driverResolution = resolveVpnDriverConfig(vpnConfig);
148
+ if (!driverResolution.success) {
149
+ return yield* new PrerequisiteRunError({
150
+ message: driverResolution.error,
151
+ hint: driverResolution.hint,
152
+ });
153
+ }
154
+
155
+ const driver = driverResolution.driver;
156
+ const wasConnected = yield* isVpnConnected(driver, runCommand);
157
+ if (wasConnected) {
158
+ continue;
159
+ }
160
+
161
+ const startCommand = makeVpnCommand(driver, "start");
162
+ const startResult = yield* runCommand(startCommand.command, startCommand.label).pipe(
163
+ Effect.mapError(
164
+ () =>
165
+ new PrerequisiteRunError({
166
+ message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
167
+ hint: missingVpnToolHint(driver),
168
+ }),
169
+ ),
170
+ );
171
+
172
+ if (startResult.exitCode !== 0) {
173
+ const stderr = startResult.stderr.trim();
174
+ return yield* new PrerequisiteRunError({
175
+ message:
176
+ stderr !== "" ? stderr : `Failed to start VPN prerequisite "${prerequisite.key}".`,
177
+ hint: missingVpnToolHint(driver),
178
+ });
179
+ }
180
+
181
+ const ready = yield* waitForVpn(driver, vpnConfig.connectTimeoutMs ?? 30000, runCommand);
182
+ if (!ready) {
183
+ return yield* new PrerequisiteRunError({
184
+ message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
185
+ hint: missingVpnToolHint(driver),
186
+ });
187
+ }
188
+
189
+ const cleanup = prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
190
+ if (cleanup === "stop-if-started") {
191
+ startedDrivers.push({ driver, cooldownMs: vpnConfig.cooldownMs ?? 0 });
192
+ }
193
+ }
194
+
195
+ const cleanup = Effect.gen(function* () {
196
+ for (const started of startedDrivers.toReversed()) {
197
+ if (started.cooldownMs > 0) {
198
+ yield* Effect.sleep(Duration.millis(started.cooldownMs));
199
+ }
200
+
201
+ const stopCommand = makeVpnCommand(started.driver, "stop");
202
+ yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
203
+ }
204
+ });
205
+
206
+ return yield* effect.pipe(Effect.ensuring(cleanup));
207
+ });
208
+ };
@@ -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>;