@blogic-cz/agent-tools 0.15.13 → 0.16.0

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.
@@ -1,5 +1,5 @@
1
1
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
- import { Clock, Context, Duration, Effect, Layer, Ref, Stream } from "effect";
2
+ import { Clock, Context, Duration, Effect, Layer, Ref, Result, Stream } from "effect";
3
3
 
4
4
  import type { DbConfig, DbMutationOperation, QueryResult, SchemaMode } from "./types";
5
5
 
@@ -9,12 +9,12 @@ import { resolveEnvTemplate } from "#shared/env-template";
9
9
  import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
10
10
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
11
11
  import { buildApiProbeArgs } from "#shared/k8s-probe";
12
+ import { missingBinaryFromSpawnFailure } from "#shared/binary-preflight";
12
13
  import { DbConfigService, TUNNEL_CHECK_INTERVAL_MS } from "./config-service";
13
- import { PSQL_MISSING_HINT, resolvePsqlSearchPath } from "./psql";
14
+ import { DbSqlClient, type DbConnection } from "./sql-client";
14
15
  import {
15
16
  DbConnectionError,
16
17
  DbMutationBlockedError,
17
- DbParseError,
18
18
  DbQueryError,
19
19
  DbTunnelError,
20
20
  type DbError,
@@ -30,6 +30,7 @@ import {
30
30
  detectSchemaError,
31
31
  getAllowedMutationOperation,
32
32
  getMutationTarget,
33
+ hasMultipleStatements,
33
34
  isValidTableName,
34
35
  isMutationQuery,
35
36
  } from "./security";
@@ -87,6 +88,7 @@ export class DbService extends Context.Service<
87
88
  Effect.scoped(
88
89
  Effect.gen(function* () {
89
90
  const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
91
+ const sqlClient = yield* DbSqlClient;
90
92
  const agentToolsConfig = yield* ConfigService;
91
93
  const dbConfig = yield* DbConfigService;
92
94
 
@@ -231,23 +233,29 @@ export class DbService extends Context.Service<
231
233
  return { stdout, stderr, exitCode };
232
234
  }),
233
235
  ).pipe(
234
- Effect.mapError(
235
- (platformError) =>
236
- new DbQueryError({
237
- message: `Command execution failed: ${String(platformError)}`,
238
- sql: "shell command",
239
- stderr: String(platformError),
240
- ...(String(platformError).includes("psql") ? { hint: PSQL_MISSING_HINT } : {}),
241
- }),
242
- ),
236
+ Effect.mapError((platformError) => {
237
+ const missing = missingBinaryFromSpawnFailure("kubectl", String(platformError));
238
+ return new DbQueryError({
239
+ message: `Command execution failed: ${String(platformError)}`,
240
+ sql: "shell command",
241
+ stderr: String(platformError),
242
+ ...(missing ? { hint: missing.hint } : {}),
243
+ });
244
+ }),
243
245
  );
244
246
 
245
247
  const checkPortOpen = (port: number) =>
246
- executeShellCommand(
247
- ChildProcess.make("nc", ["-z", "localhost", String(port)], {
248
- stdout: "pipe",
249
- stderr: "pipe",
250
- }),
248
+ Effect.tryPromise(async () => {
249
+ const socket = await Bun.connect({
250
+ hostname: "127.0.0.1",
251
+ port,
252
+ socket: { data: () => undefined },
253
+ });
254
+ socket.end();
255
+ return true;
256
+ }).pipe(
257
+ Effect.timeout(TUNNEL_CHECK_INTERVAL_MS),
258
+ Effect.orElseSucceed(() => false),
251
259
  );
252
260
 
253
261
  const runWithVpnPrerequisites = <E>(
@@ -284,11 +292,7 @@ export class DbService extends Context.Service<
284
292
  return false;
285
293
  }
286
294
 
287
- const result = yield* checkPortOpen(port).pipe(
288
- Effect.catch(() => Effect.succeed({ exitCode: 1 })),
289
- );
290
-
291
- if (result.exitCode === 0) {
295
+ if (yield* checkPortOpen(port)) {
292
296
  return true;
293
297
  }
294
298
 
@@ -378,69 +382,34 @@ export class DbService extends Context.Service<
378
382
  return result.exitCode === 0;
379
383
  });
380
384
 
381
- const buildPsqlCommand = (
382
- config: DbConfig,
383
- sql: string,
384
- password: string,
385
- useTuplesOnly: boolean,
386
- ) => {
387
- const args = [
388
- "-h",
389
- config.host,
390
- "-p",
391
- String(config.port),
392
- "-U",
393
- config.user,
394
- "-d",
395
- config.database,
396
- ];
397
-
398
- const commandArgs = useTuplesOnly
399
- ? [...args, "-t", "-A", "-c", sql]
400
- : [...args, "-c", sql];
401
-
402
- return ChildProcess.make("psql", commandArgs, {
403
- stdout: "pipe",
404
- stderr: "pipe",
405
- env: {
406
- ...process.env,
407
- PATH: resolvePsqlSearchPath(process.env.PATH),
408
- ...(password ? { PGPASSWORD: password } : {}),
409
- ...(isFullyReadOnly(config)
410
- ? { PGOPTIONS: "-c default_transaction_read_only=on" }
411
- : {}),
412
- } as Record<string, string>,
413
- });
414
- };
385
+ const toConnection = (config: DbConfig, password: string): DbConnection => ({
386
+ host: config.host,
387
+ port: config.port,
388
+ user: config.user,
389
+ database: config.database,
390
+ password,
391
+ readOnly: isFullyReadOnly(config),
392
+ });
415
393
 
416
- const fetchTableNamesForError = Effect.fn("DbService.fetchTableNamesForError")(function* (
417
- config: DbConfig,
418
- password: string,
419
- ) {
420
- const command = buildPsqlCommand(
421
- config,
422
- `SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL}) ORDER BY schemaname, tablename;`,
423
- password,
424
- true,
425
- );
426
- const result = yield* executeShellCommand(command).pipe(
427
- Effect.catch(() =>
428
- Effect.succeed({
429
- stdout: "",
430
- stderr: "",
431
- exitCode: 1,
432
- }),
394
+ const runSql = (config: DbConfig, password: string, sql: string) =>
395
+ sqlClient.run(toConnection(config, password), sql);
396
+
397
+ const fetchSingleColumn = (config: DbConfig, password: string, sql: string) =>
398
+ runSql(config, password, sql).pipe(
399
+ Effect.map((outcome) =>
400
+ outcome.rows
401
+ .map((row) => String(Object.values(row)[0] ?? ""))
402
+ .filter((value) => value.length > 0),
433
403
  ),
404
+ Effect.orElseSucceed(() => [] as string[]),
434
405
  );
435
- if (result.exitCode !== 0) {
436
- return [] as string[];
437
- }
438
406
 
439
- return result.stdout
440
- .trim()
441
- .split("\n")
442
- .filter((name) => name.length > 0);
443
- });
407
+ const fetchTableNamesForError = (config: DbConfig, password: string) =>
408
+ fetchSingleColumn(
409
+ config,
410
+ password,
411
+ `SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL}) ORDER BY schemaname, tablename;`,
412
+ );
444
413
 
445
414
  const fetchColumnNamesForError = Effect.fn("DbService.fetchColumnNamesForError")(function* (
446
415
  config: DbConfig,
@@ -458,29 +427,11 @@ export class DbService extends Context.Service<
458
427
  ? `AND table_schema = '${escapedSchemaName}'`
459
428
  : `AND table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
460
429
 
461
- const command = buildPsqlCommand(
430
+ return yield* fetchSingleColumn(
462
431
  config,
463
- `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
464
432
  password,
465
- true,
466
- );
467
- const result = yield* executeShellCommand(command).pipe(
468
- Effect.catch(() =>
469
- Effect.succeed({
470
- stdout: "",
471
- stderr: "",
472
- exitCode: 1,
473
- }),
474
- ),
433
+ `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
475
434
  );
476
- if (result.exitCode !== 0) {
477
- return [] as string[];
478
- }
479
-
480
- return result.stdout
481
- .trim()
482
- .split("\n")
483
- .filter((name) => name.length > 0);
484
435
  });
485
436
 
486
437
  const executeSelectQuery = Effect.fn("DbService.executeSelectQuery")(function* (
@@ -492,16 +443,15 @@ export class DbService extends Context.Service<
492
443
  limit?: number,
493
444
  ) {
494
445
  const selectSql = sql.trim().replace(/;\s*$/, "");
495
- const wrappedSql = `SELECT json_agg(t) FROM (${selectSql}) t;`;
496
- const command = buildPsqlCommand(config, wrappedSql, password, true);
497
- const result = yield* executeShellCommand(command);
446
+ const outcome = yield* Effect.result(runSql(config, password, selectSql));
498
447
  const endTime = yield* Clock.currentTimeMillis;
499
448
 
500
- if (result.exitCode !== 0) {
501
- const schemaError = detectSchemaError(result.stderr, sql);
449
+ if (Result.isFailure(outcome)) {
450
+ const failureMessage = outcome.failure.message.trim();
451
+ const schemaError = detectSchemaError(failureMessage, sql);
502
452
  const baseResult: QueryResult = {
503
453
  success: false,
504
- error: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
454
+ error: failureMessage || "Query failed",
505
455
  executionTimeMs: Number(endTime) - startTimeMs,
506
456
  };
507
457
 
@@ -527,32 +477,10 @@ export class DbService extends Context.Service<
527
477
  };
528
478
  }
529
479
 
530
- return yield* new DbQueryError({
531
- message: baseResult.error ?? "Query failed",
532
- sql,
533
- stderr: result.stderr.trim() || undefined,
534
- });
535
- }
536
-
537
- const trimmedOutput = result.stdout.trim();
538
- if (!trimmedOutput || trimmedOutput === "null") {
539
- return {
540
- success: true,
541
- data: [],
542
- rowCount: 0,
543
- executionTimeMs: Number(endTime) - startTimeMs,
544
- };
480
+ return yield* outcome.failure;
545
481
  }
546
482
 
547
- const rawData = yield* Effect.try({
548
- try: () => JSON.parse(trimmedOutput) as Record<string, unknown>[],
549
- catch: () =>
550
- new DbParseError({
551
- message: "Failed to parse query result as JSON.",
552
- rawOutput: trimmedOutput.slice(0, 500),
553
- }),
554
- });
555
-
483
+ const rawData = outcome.success.rows;
556
484
  const transformed = applyTransform
557
485
  ? transformQueryResult(rawData, limit)
558
486
  : {
@@ -577,25 +505,13 @@ export class DbService extends Context.Service<
577
505
  password: string,
578
506
  startTimeMs: number,
579
507
  ) {
580
- const command = buildPsqlCommand(config, sql, password, false);
581
- const result = yield* executeShellCommand(command);
508
+ const outcome = yield* runSql(config, password, sql);
582
509
  const endTime = yield* Clock.currentTimeMillis;
583
-
584
- if (result.exitCode !== 0) {
585
- return yield* new DbQueryError({
586
- message: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
587
- sql,
588
- stderr: result.stderr.trim() || undefined,
589
- });
590
- }
591
-
592
- const output = result.stdout.trim();
593
- const rowCountMatch = output.match(/(?:UPDATE|DELETE|INSERT \d+)\s+(\d+)/i);
594
- const rowCount = rowCountMatch ? parseInt(rowCountMatch[1], 10) : 0;
510
+ const rowCount = outcome.rowCount;
595
511
 
596
512
  return {
597
513
  success: true,
598
- message: output,
514
+ message: `${outcome.command} ${rowCount}`.trim(),
599
515
  rowCount,
600
516
  executionTimeMs: Number(endTime) - startTimeMs,
601
517
  };
@@ -748,6 +664,15 @@ export class DbService extends Context.Service<
748
664
  const startTimeMs = yield* Clock.currentTimeMillis;
749
665
  const resolvedConfig = yield* resolveDbConfig(config, env);
750
666
  const password = yield* resolvePassword(resolvedConfig, env);
667
+ if (hasMultipleStatements(sql)) {
668
+ return yield* new DbQueryError({
669
+ message:
670
+ "Multiple SQL statements in a single call are not allowed; the mutation guard only inspects the first statement.",
671
+ sql,
672
+ hint: "Send one statement per call.",
673
+ });
674
+ }
675
+
751
676
  const mutation = isMutationQuery(sql);
752
677
  const mutationOperation = mutation ? getAllowedMutationOperation(sql) : undefined;
753
678
  const mutationTarget = mutation ? getMutationTarget(sql) : undefined;
@@ -0,0 +1,80 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+
3
+ import { DbQueryError } from "./errors";
4
+
5
+ export type DbConnection = {
6
+ readonly host: string;
7
+ readonly port: number;
8
+ readonly user: string;
9
+ readonly database: string;
10
+ readonly password: string;
11
+ readonly readOnly: boolean;
12
+ };
13
+
14
+ export type DbQueryOutcome = {
15
+ readonly rows: Record<string, unknown>[];
16
+ readonly rowCount: number;
17
+ readonly command: string;
18
+ };
19
+
20
+ type BunQueryResult = unknown[] & { count?: number; command?: string };
21
+
22
+ export const toErrorMessage = (cause: unknown): string =>
23
+ cause instanceof Error ? cause.message : String(cause);
24
+
25
+ const runWithBunSql = (connection: DbConnection, sql: string) =>
26
+ Effect.tryPromise({
27
+ try: async (): Promise<DbQueryOutcome> => {
28
+ const client = new Bun.SQL({
29
+ hostname: connection.host,
30
+ port: connection.port,
31
+ username: connection.user,
32
+ password: connection.password,
33
+ database: connection.database,
34
+ max: 1,
35
+ // Startup-packet GUC: covers every pooled connection, unlike a post-connect SET.
36
+ ...(connection.readOnly ? { connection: { default_transaction_read_only: "on" } } : {}),
37
+ });
38
+
39
+ try {
40
+ if (connection.readOnly) {
41
+ const [state] = (await client.unsafe("SHOW transaction_read_only")) as {
42
+ transaction_read_only?: string;
43
+ }[];
44
+ if (state?.transaction_read_only !== "on") {
45
+ throw new Error(
46
+ "Refusing to query: the read-only session setting was not accepted by the server, so writes would not be blocked.",
47
+ );
48
+ }
49
+ }
50
+
51
+ const result = (await client.unsafe(sql)) as BunQueryResult;
52
+ const rows = Array.isArray(result) ? (result as Record<string, unknown>[]) : [];
53
+
54
+ return {
55
+ rows,
56
+ rowCount: result.count ?? rows.length,
57
+ command: result.command ?? "",
58
+ };
59
+ } finally {
60
+ // A rejecting close() would replace the real query error via try/finally semantics.
61
+ await client.close().catch(() => undefined);
62
+ }
63
+ },
64
+ catch: (cause) => {
65
+ const message = toErrorMessage(cause);
66
+ return new DbQueryError({ message, sql, stderr: message });
67
+ },
68
+ });
69
+
70
+ export class DbSqlClient extends Context.Service<
71
+ DbSqlClient,
72
+ {
73
+ readonly run: (
74
+ connection: DbConnection,
75
+ sql: string,
76
+ ) => Effect.Effect<DbQueryOutcome, DbQueryError>;
77
+ }
78
+ >()("@agent-tools/DbSqlClient") {
79
+ static readonly layer = Layer.succeed(DbSqlClient, { run: runWithBunSql });
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
 
3
- export class GitHubCommandError extends Schema.TaggedErrorClass<GitHubCommandError>()(
3
+ export class GitHubCommandError extends Schema.TaggedError<GitHubCommandError>()(
4
4
  "GitHubCommandError",
5
5
  {
6
6
  message: Schema.String,
@@ -13,7 +13,7 @@ export class GitHubCommandError extends Schema.TaggedErrorClass<GitHubCommandErr
13
13
  },
14
14
  ) {}
15
15
 
16
- export class GitHubNotFoundError extends Schema.TaggedErrorClass<GitHubNotFoundError>()(
16
+ export class GitHubNotFoundError extends Schema.TaggedError<GitHubNotFoundError>()(
17
17
  "GitHubNotFoundError",
18
18
  {
19
19
  message: Schema.String,
@@ -25,25 +25,22 @@ export class GitHubNotFoundError extends Schema.TaggedErrorClass<GitHubNotFoundE
25
25
  },
26
26
  ) {}
27
27
 
28
- export class GitHubAuthError extends Schema.TaggedErrorClass<GitHubAuthError>()("GitHubAuthError", {
28
+ export class GitHubAuthError extends Schema.TaggedError<GitHubAuthError>()("GitHubAuthError", {
29
29
  message: Schema.String,
30
30
  hint: Schema.optionalKey(Schema.String),
31
31
  nextCommand: Schema.optionalKey(Schema.String),
32
32
  retryable: Schema.optionalKey(Schema.Boolean),
33
33
  }) {}
34
34
 
35
- export class GitHubMergeError extends Schema.TaggedErrorClass<GitHubMergeError>()(
36
- "GitHubMergeError",
37
- {
38
- message: Schema.String,
39
- reason: Schema.Literals(["conflicts", "checks_failing", "branch_protected", "unknown"]),
40
- hint: Schema.optionalKey(Schema.String),
41
- nextCommand: Schema.optionalKey(Schema.String),
42
- retryable: Schema.optionalKey(Schema.Boolean),
43
- },
44
- ) {}
35
+ export class GitHubMergeError extends Schema.TaggedError<GitHubMergeError>()("GitHubMergeError", {
36
+ message: Schema.String,
37
+ reason: Schema.Literals(["conflicts", "checks_failing", "branch_protected", "unknown"]),
38
+ hint: Schema.optionalKey(Schema.String),
39
+ nextCommand: Schema.optionalKey(Schema.String),
40
+ retryable: Schema.optionalKey(Schema.Boolean),
41
+ }) {}
45
42
 
46
- export class GitHubTimeoutError extends Schema.TaggedErrorClass<GitHubTimeoutError>()(
43
+ export class GitHubTimeoutError extends Schema.TaggedError<GitHubTimeoutError>()(
47
44
  "GitHubTimeoutError",
48
45
  {
49
46
  message: Schema.String,
@@ -2,6 +2,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
2
  import { Effect, Stream } from "effect";
3
3
 
4
4
  import { GitHubCommandError } from "#gh/errors";
5
+ import { missingBinaryFromSpawnFailure } from "#shared/binary-preflight";
5
6
 
6
7
  export type LocalCommandResult = {
7
8
  stdout: string;
@@ -69,15 +70,16 @@ export const runLocalCommand = Effect.fn("pr.runLocalCommand")(function* (
69
70
  return commandResult;
70
71
  }),
71
72
  ).pipe(
72
- Effect.mapError(
73
- (error) =>
74
- new GitHubCommandError({
75
- command: [binary, ...args].join(" "),
76
- exitCode: -1,
77
- stderr: String(error),
78
- message: String(error),
79
- }),
80
- ),
73
+ Effect.mapError((error) => {
74
+ const missing = missingBinaryFromSpawnFailure(binary, String(error));
75
+ return new GitHubCommandError({
76
+ command: [binary, ...args].join(" "),
77
+ exitCode: -1,
78
+ stderr: String(error),
79
+ message: missing?.message ?? String(error),
80
+ ...(missing ? { hint: missing.hint } : {}),
81
+ });
82
+ }),
81
83
  );
82
84
 
83
85
  return result;
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type { AgentToolsConfig, ObservabilityConfig, ObservabilityEnvTarget } from "./config/index";
2
2
 
3
+ export type { BaseResult, Environment, OutputFormat, ToolResult } from "./shared/types";
4
+
3
5
  export {
4
6
  AuditService,
5
7
  AuditServiceLayer,
@@ -1,6 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
 
3
- export class K8sContextError extends Schema.TaggedErrorClass<K8sContextError>()("K8sContextError", {
3
+ export class K8sContextError extends Schema.TaggedError<K8sContextError>()("K8sContextError", {
4
4
  message: Schema.String,
5
5
  clusterId: Schema.String,
6
6
  hint: Schema.optionalKey(Schema.String),
@@ -8,7 +8,7 @@ export class K8sContextError extends Schema.TaggedErrorClass<K8sContextError>()(
8
8
  retryable: Schema.optionalKey(Schema.Boolean),
9
9
  }) {}
10
10
 
11
- export class K8sCommandError extends Schema.TaggedErrorClass<K8sCommandError>()("K8sCommandError", {
11
+ export class K8sCommandError extends Schema.TaggedError<K8sCommandError>()("K8sCommandError", {
12
12
  message: Schema.String,
13
13
  command: Schema.String,
14
14
  exitCode: Schema.optionalKey(Schema.Number),
@@ -18,7 +18,7 @@ export class K8sCommandError extends Schema.TaggedErrorClass<K8sCommandError>()(
18
18
  retryable: Schema.optionalKey(Schema.Boolean),
19
19
  }) {}
20
20
 
21
- export class K8sTimeoutError extends Schema.TaggedErrorClass<K8sTimeoutError>()("K8sTimeoutError", {
21
+ export class K8sTimeoutError extends Schema.TaggedError<K8sTimeoutError>()("K8sTimeoutError", {
22
22
  message: Schema.String,
23
23
  command: Schema.String,
24
24
  timeoutMs: Schema.Number,
@@ -27,7 +27,7 @@ export class K8sTimeoutError extends Schema.TaggedErrorClass<K8sTimeoutError>()(
27
27
  retryable: Schema.optionalKey(Schema.Boolean),
28
28
  }) {}
29
29
 
30
- export class K8sDangerousCommandError extends Schema.TaggedErrorClass<K8sDangerousCommandError>()(
30
+ export class K8sDangerousCommandError extends Schema.TaggedError<K8sDangerousCommandError>()(
31
31
  "K8sDangerousCommandError",
32
32
  {
33
33
  message: Schema.String,
@@ -14,6 +14,7 @@ import {
14
14
  import { ConfigService, getToolConfig } from "#config";
15
15
  import type { K8sConfig } from "#config";
16
16
  import { collectProcessOutput, quoteShellArg } from "#shared/exec";
17
+ import { missingBinaryFromSpawnFailure } from "#shared/binary-preflight";
17
18
  import { resolveEnvTemplate } from "#shared/env-template";
18
19
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
19
20
  import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
@@ -135,15 +136,16 @@ export class K8sService extends Context.Service<
135
136
  }),
136
137
  ).pipe(
137
138
  Effect.timeoutOption(timeoutMs),
138
- Effect.mapError(
139
- (platformError) =>
140
- new K8sCommandError({
141
- message: `Command execution failed: ${String(platformError)}`,
142
- command: commandStr,
143
- exitCode: -1,
144
- stderr: String(platformError),
145
- }),
146
- ),
139
+ Effect.mapError((platformError) => {
140
+ const missing = missingBinaryFromSpawnFailure("kubectl", String(platformError));
141
+ return new K8sCommandError({
142
+ message: `Command execution failed: ${String(platformError)}`,
143
+ command: commandStr,
144
+ exitCode: -1,
145
+ stderr: String(platformError),
146
+ ...(missing ? { hint: missing.hint } : {}),
147
+ });
148
+ }),
147
149
  );
148
150
 
149
151
  const runPrerequisiteCommand = (command: ChildProcess.Command, label: string) =>
@@ -153,15 +155,16 @@ export class K8sService extends Context.Service<
153
155
  return yield* collectProcessOutput(process);
154
156
  }),
155
157
  ).pipe(
156
- Effect.mapError(
157
- (platformError) =>
158
- new K8sCommandError({
159
- message: `Prerequisite command failed (${label}): ${String(platformError)}`,
160
- command: label,
161
- exitCode: -1,
162
- stderr: String(platformError),
163
- }),
164
- ),
158
+ Effect.mapError((platformError) => {
159
+ const missing = missingBinaryFromSpawnFailure("kubectl", String(platformError));
160
+ return new K8sCommandError({
161
+ message: `Prerequisite command failed (${label}): ${String(platformError)}`,
162
+ command: label,
163
+ exitCode: -1,
164
+ stderr: String(platformError),
165
+ ...(missing ? { hint: missing.hint } : {}),
166
+ });
167
+ }),
165
168
  );
166
169
 
167
170
  /**
@@ -276,6 +279,7 @@ export class K8sService extends Context.Service<
276
279
  const k8sConfig = yield* requireK8sConfig(profile);
277
280
  const timeoutMs = k8sConfig.timeoutMs ?? 60000;
278
281
  const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
282
+
279
283
  const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
280
284
  const reachableWithoutPrerequisites = yield* probeApiReachable(
281
285
  context,
@@ -314,15 +318,16 @@ export class K8sService extends Context.Service<
314
318
  }),
315
319
  ).pipe(
316
320
  Effect.timeoutOption(timeoutMs),
317
- Effect.mapError(
318
- (platformError) =>
319
- new K8sCommandError({
320
- message: `Command execution failed: ${String(platformError)}`,
321
- command: fullCommand,
322
- exitCode: -1,
323
- stderr: String(platformError),
324
- }),
325
- ),
321
+ Effect.mapError((platformError) => {
322
+ const missing = missingBinaryFromSpawnFailure("kubectl", String(platformError));
323
+ return new K8sCommandError({
324
+ message: `Command execution failed: ${String(platformError)}`,
325
+ command: fullCommand,
326
+ exitCode: -1,
327
+ stderr: String(platformError),
328
+ ...(missing ? { hint: missing.hint } : {}),
329
+ });
330
+ }),
326
331
  );
327
332
 
328
333
  if (Option.isNone(resultOption)) {
@@ -473,7 +478,7 @@ export class K8sService extends Context.Service<
473
478
  `Remote realpath exited with code ${realpathResult.exitCode}`,
474
479
  command: realpathResult.command,
475
480
  exitCode: realpathResult.exitCode,
476
- stderr: realpathResult.stderr || undefined,
481
+ ...(realpathResult.stderr ? { stderr: realpathResult.stderr } : {}),
477
482
  });
478
483
  }
479
484
  const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");