@blogic-cz/agent-tools 0.15.5 → 0.15.7

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.15.5",
3
+ "version": "0.15.7",
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",
@@ -17,6 +17,7 @@ import { K8sService, K8sServiceLayer } from "./service";
17
17
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
18
18
  import type { K8sConfig } from "#config";
19
19
  import { K8sContextError } from "./errors";
20
+ import { hasKubernetesConfig, selectK8sProfile } from "./profile";
20
21
  import {
21
22
  transformDescribe,
22
23
  transformGenericKubectl,
@@ -60,6 +61,21 @@ const resolveEnv = (
60
61
  });
61
62
  });
62
63
 
64
+ const resolveEnvAndProfile = (
65
+ envOption: Option.Option<string>,
66
+ profileOption: Option.Option<string>,
67
+ config: Parameters<typeof getDefaultEnvironment>[0],
68
+ ) =>
69
+ Effect.gen(function* () {
70
+ const resolvedEnv = yield* resolveEnv(envOption, config);
71
+ const profileName = selectK8sProfile(
72
+ config?.kubernetes,
73
+ Option.getOrUndefined(profileOption),
74
+ resolvedEnv,
75
+ );
76
+ return { env: resolvedEnv, profile: profileName };
77
+ });
78
+
63
79
  type CommonK8sCommandOptions = {
64
80
  readonly env: Option.Option<string>;
65
81
  readonly dryRun: boolean;
@@ -67,26 +83,36 @@ type CommonK8sCommandOptions = {
67
83
  readonly profile: Option.Option<string>;
68
84
  };
69
85
 
86
+ const noKubernetesConfigResult = (): CommandResult => ({
87
+ success: false,
88
+ error: "No Kubernetes configuration found",
89
+ hint: "Add a 'kubernetes' section to agent-tools.json5 with clusterId and namespaces.",
90
+ nextCommand:
91
+ "echo '{ kubernetes: { default: { clusterId: \"my-cluster\" } } }' > agent-tools.json5",
92
+ executionTimeMs: 0,
93
+ });
94
+
70
95
  const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
71
96
  Effect.gen(function* () {
72
97
  const config = yield* ConfigService;
73
- const profileName = Option.getOrUndefined(options.profile);
98
+
99
+ // Check for a missing 'kubernetes' section before resolving --env, so that case still
100
+ // surfaces "add a kubernetes section" instead of an env-resolution error (e.g. prod-safety).
101
+ if (!hasKubernetesConfig(config?.kubernetes)) {
102
+ return noKubernetesConfigResult();
103
+ }
104
+
105
+ const { env: resolvedEnv, profile: profileName } = yield* resolveEnvAndProfile(
106
+ options.env,
107
+ options.profile,
108
+ config,
109
+ );
74
110
  const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes", profileName);
75
111
 
76
112
  if (!k8sConfig) {
77
- const result: CommandResult = {
78
- success: false,
79
- error: "No Kubernetes configuration found",
80
- hint: "Add a 'kubernetes' section to agent-tools.json5 with clusterId and namespaces.",
81
- nextCommand:
82
- "echo '{ kubernetes: { default: { clusterId: \"my-cluster\" } } }' > agent-tools.json5",
83
- executionTimeMs: 0,
84
- };
85
- return result;
113
+ return noKubernetesConfigResult();
86
114
  }
87
115
 
88
- const resolvedEnv = yield* resolveEnv(options.env, config);
89
-
90
116
  const k8sService = yield* K8sService;
91
117
  const result = yield* k8sService.runKubectl(command, options.dryRun, profileName).pipe(
92
118
  Effect.catchTags({
@@ -94,8 +120,10 @@ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
94
120
  const errorResult: CommandResult = {
95
121
  success: false,
96
122
  error: error.message,
97
- hint: `Verify cluster ID "${k8sConfig.clusterId}" matches a context in kubectl config. Run: kubectl config get-contexts`,
98
- nextCommand: "kubectl config get-contexts",
123
+ hint:
124
+ error.hint ??
125
+ `Verify cluster ID "${k8sConfig.clusterId}" matches a context in kubectl config. Run: kubectl config get-contexts`,
126
+ ...(error.hint ? {} : { nextCommand: "kubectl config get-contexts" }),
99
127
  executionTimeMs: 0,
100
128
  };
101
129
  return Effect.succeed(errorResult);
@@ -190,8 +218,11 @@ const resolveStructuredNamespace = (
190
218
  if (explicitNamespace) return explicitNamespace;
191
219
 
192
220
  const config = yield* ConfigService;
193
- const resolvedEnv = yield* resolveEnv(envOption, config);
194
- const profileName = Option.getOrUndefined(profileOption);
221
+ const { env: resolvedEnv, profile: profileName } = yield* resolveEnvAndProfile(
222
+ envOption,
223
+ profileOption,
224
+ config,
225
+ );
195
226
  const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes", profileName);
196
227
 
197
228
  if (!k8sConfig) return undefined;
@@ -0,0 +1,22 @@
1
+ // --profile wins if given; else a kubernetes.<env> section (different cluster per env) wins; else undefined (getToolConfig default).
2
+ export function selectK8sProfile(
3
+ kubernetesSection: Record<string, unknown> | undefined,
4
+ explicitProfile: string | undefined,
5
+ resolvedEnv: string,
6
+ ): string | undefined {
7
+ if (explicitProfile) {
8
+ return explicitProfile;
9
+ }
10
+
11
+ if (kubernetesSection && Object.hasOwn(kubernetesSection, resolvedEnv)) {
12
+ return resolvedEnv;
13
+ }
14
+
15
+ return undefined;
16
+ }
17
+
18
+ export function hasKubernetesConfig(
19
+ kubernetesSection: Record<string, unknown> | undefined,
20
+ ): boolean {
21
+ return !!kubernetesSection && Object.keys(kubernetesSection).length > 0;
22
+ }
@@ -16,6 +16,7 @@ import type { K8sConfig } from "#config";
16
16
  import { collectProcessOutput, quoteShellArg } from "#shared/exec";
17
17
  import { resolveEnvTemplate } from "#shared/env-template";
18
18
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
19
+ import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
19
20
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
20
21
  import { buildApiProbeArgs } from "#shared/k8s-probe";
21
22
  import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
@@ -275,14 +276,23 @@ export class K8sService extends Context.Service<
275
276
  const k8sConfig = yield* requireK8sConfig(profile);
276
277
  const timeoutMs = k8sConfig.timeoutMs ?? 60000;
277
278
  const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
279
+ const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
280
+ const reachableWithoutPrerequisites = yield* probeApiReachable(
281
+ context,
282
+ kubeconfig,
283
+ apiProbeTimeoutMs,
284
+ );
285
+ const vpnGated = normalizeProfilePrerequisites(k8sConfig).some(
286
+ (prerequisite) => prerequisite.type === "vpn",
287
+ );
278
288
  return yield* runWithProfilePrerequisites(
279
289
  config ?? {},
280
290
  k8sConfig,
281
291
  runPrerequisiteCommand,
282
292
  Effect.gen(function* () {
283
- const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
284
-
285
- const reachable = yield* probeApiReachable(context, kubeconfig, apiProbeTimeoutMs);
293
+ const reachable =
294
+ reachableWithoutPrerequisites ||
295
+ (vpnGated && (yield* probeApiReachable(context, kubeconfig, apiProbeTimeoutMs)));
286
296
  if (!reachable) {
287
297
  return yield* new K8sContextError({
288
298
  message: `Kubernetes API server (${k8sConfig.clusterId}) not reachable within ${apiProbeTimeoutMs}ms. VPN likely not connected, or the cluster API is degraded.`,
@@ -332,6 +342,7 @@ export class K8sService extends Context.Service<
332
342
  command: fullCommand,
333
343
  };
334
344
  }),
345
+ { alreadySatisfied: apiProbeTimeoutMs > 0 && reachableWithoutPrerequisites },
335
346
  ).pipe(
336
347
  Effect.mapError((error) =>
337
348
  isPrerequisiteRunError(error)
@@ -78,32 +78,38 @@ export async function stopWhenIdle(
78
78
  const deadline = now() + init.disconnectTimeoutMs;
79
79
  let evidence = "VPN stop did not produce confirmed disconnected status.";
80
80
  try {
81
- let remaining = deadline - now();
81
+ const remaining = deadline - now();
82
82
  if (remaining > 0) {
83
83
  const stop = await runCommand("stop", remaining);
84
- remaining = deadline - now();
85
84
  if (stop.exitCode !== 0) {
86
- evidence = "VPN stop command failed; ownership is unknown and stop will not be retried.";
85
+ evidence = `VPN stop command failed (exit ${stop.exitCode}); ownership is unknown and stop will not be retried.`;
87
86
  } else {
88
- const confirmDisconnected = async (): Promise<boolean> => {
87
+ const stillConnected =
88
+ "VPN still reported connected after stop when the disconnect deadline expired.";
89
+ const confirmDisconnected = async (): Promise<true | string> => {
89
90
  const statusRemaining = deadline - now();
90
- if (statusRemaining <= 0) return false;
91
+ if (statusRemaining <= 0) {
92
+ return "VPN stop was dispatched, but the disconnect deadline expired before status could be confirmed.";
93
+ }
91
94
  const status = await runCommand("status", statusRemaining);
92
- remaining = deadline - now();
93
95
  const connected = parseVpnStatus(init.driver, status);
94
96
  if (connected === false) return true;
95
- if (connected === undefined || remaining <= 0) return false;
97
+ if (connected === undefined) {
98
+ return status.exitCode === 0
99
+ ? "VPN status output after stop was unparseable; ownership is unknown."
100
+ : `VPN status command after stop failed (exit ${status.exitCode}); ownership is unknown.`;
101
+ }
96
102
  const sleepRemaining = deadline - now();
97
- if (sleepRemaining <= 0) return false;
103
+ if (sleepRemaining <= 0) return stillConnected;
98
104
  await sleep(Math.min(250, sleepRemaining));
99
- remaining = deadline - now();
100
- return remaining > 0 && confirmDisconnected();
105
+ return deadline - now() > 0 ? confirmDisconnected() : stillConnected;
101
106
  };
102
- if (await confirmDisconnected()) {
107
+ const confirmed = await confirmDisconnected();
108
+ if (confirmed === true) {
103
109
  store.commitStop(guard, true, "VPN stop confirmed disconnected.", now());
104
110
  return;
105
111
  }
106
- evidence = "VPN status failed, stayed connected, or was unparseable after stop.";
112
+ evidence = confirmed;
107
113
  }
108
114
  } else {
109
115
  evidence = "VPN disconnect deadline expired before a command could safely start.";
@@ -742,6 +742,8 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
742
742
  runCommand: PrerequisiteCommandRunner<CommandError>,
743
743
  effect: Effect.Effect<A, E, never>,
744
744
  options?: {
745
+ /** Caller proved the target is already reachable: skip the VPN entirely (no status, no lease). */
746
+ alreadySatisfied?: boolean;
745
747
  tryWithoutPrerequisites?: boolean;
746
748
  runGuardianInProcess?: boolean;
747
749
  guardianSpawn?: GuardianSpawner;
@@ -750,7 +752,7 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
750
752
  const vpnPrerequisites = normalizeProfilePrerequisites(profile).filter(
751
753
  (prerequisite) => prerequisite.type === "vpn",
752
754
  );
753
- if (vpnPrerequisites.length === 0) return effect;
755
+ if (vpnPrerequisites.length === 0 || options?.alreadySatisfied === true) return effect;
754
756
 
755
757
  return Effect.gen(function* () {
756
758
  const tryDirect = () => effect.pipe(Effect.result);