@blogic-cz/agent-tools 0.15.12 → 0.15.14

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.12",
3
+ "version": "0.15.14",
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",
@@ -39,90 +39,38 @@
39
39
  },
40
40
  "files": [
41
41
  "src/",
42
- "dist/",
43
42
  "schemas/",
44
43
  "README.md",
45
44
  "LICENSE"
46
45
  ],
47
46
  "type": "module",
48
- "types": "./dist/index.d.ts",
47
+ "types": "./src/index.ts",
49
48
  "imports": {
50
- "#shared": {
51
- "types": "./dist/shared/index.d.ts",
52
- "default": "./src/shared/index.ts"
53
- },
54
- "#shared/*": {
55
- "types": "./dist/shared/*.d.ts",
56
- "default": "./src/shared/*.ts"
57
- },
58
- "#config": {
59
- "types": "./dist/config/index.d.ts",
60
- "default": "./src/config/index.ts"
61
- },
62
- "#config/*": {
63
- "types": "./dist/config/*.d.ts",
64
- "default": "./src/config/*.ts"
65
- },
66
- "#gh/*": {
67
- "types": "./dist/gh-tool/*.d.ts",
68
- "default": "./src/gh-tool/*.ts"
69
- },
70
- "#k8s/*": {
71
- "types": "./dist/k8s-tool/*.d.ts",
72
- "default": "./src/k8s-tool/*.ts"
73
- },
74
- "#db/*": {
75
- "types": "./dist/db-tool/*.d.ts",
76
- "default": "./src/db-tool/*.ts"
77
- },
78
- "#observability/*": {
79
- "types": "./dist/observability-tool/*.d.ts",
80
- "default": "./src/observability-tool/*.ts"
81
- },
82
- "#logs/*": {
83
- "types": "./dist/logs-tool/*.d.ts",
84
- "default": "./src/logs-tool/*.ts"
85
- },
86
- "#az/*": {
87
- "types": "./dist/az-tool/*.d.ts",
88
- "default": "./src/az-tool/*.ts"
89
- },
90
- "#guard": {
91
- "types": "./dist/credential-guard/index.d.ts",
92
- "default": "./src/credential-guard/index.ts"
93
- },
94
- "#session/*": {
95
- "types": "./dist/session-tool/*.d.ts",
96
- "default": "./src/session-tool/*.ts"
97
- }
49
+ "#shared": "./src/shared/index.ts",
50
+ "#shared/*": "./src/shared/*.ts",
51
+ "#config": "./src/config/index.ts",
52
+ "#config/*": "./src/config/*.ts",
53
+ "#gh/*": "./src/gh-tool/*.ts",
54
+ "#k8s/*": "./src/k8s-tool/*.ts",
55
+ "#db/*": "./src/db-tool/*.ts",
56
+ "#observability/*": "./src/observability-tool/*.ts",
57
+ "#logs/*": "./src/logs-tool/*.ts",
58
+ "#az/*": "./src/az-tool/*.ts",
59
+ "#guard": "./src/credential-guard/index.ts",
60
+ "#session/*": "./src/session-tool/*.ts"
98
61
  },
99
62
  "exports": {
100
- ".": {
101
- "types": "./dist/index.d.ts",
102
- "default": "./src/index.ts"
103
- },
104
- "./credential-guard": {
105
- "types": "./dist/credential-guard/index.d.ts",
106
- "default": "./src/credential-guard/index.ts"
107
- },
108
- "./config": {
109
- "types": "./dist/config/index.d.ts",
110
- "default": "./src/config/index.ts"
111
- },
112
- "./audit": {
113
- "types": "./dist/shared/audit.d.ts",
114
- "default": "./src/shared/audit.ts"
115
- },
116
- "./k8s-probe": {
117
- "types": "./dist/shared/k8s-probe.d.ts",
118
- "default": "./src/shared/k8s-probe.ts"
119
- }
63
+ ".": "./src/index.ts",
64
+ "./credential-guard": "./src/credential-guard/index.ts",
65
+ "./config": "./src/config/index.ts",
66
+ "./audit": "./src/shared/audit.ts",
67
+ "./k8s-probe": "./src/shared/k8s-probe.ts",
68
+ "./prerequisites": "./src/shared/prerequisites/index.ts"
120
69
  },
121
70
  "publishConfig": {
122
71
  "access": "public"
123
72
  },
124
73
  "scripts": {
125
- "build:types": "tsc -p tsconfig.declarations.json",
126
74
  "check": "bun check.ts",
127
75
  "check:ci": "bun check.ts ci",
128
76
  "format": "oxfmt",
@@ -154,6 +102,6 @@
154
102
  "@effect/platform-node-shared": "4.0.0-beta.98"
155
103
  },
156
104
  "engines": {
157
- "bun": ">=1.0.0"
105
+ "bun": ">=1.3.0"
158
106
  }
159
107
  }
File without changes
@@ -141,7 +141,7 @@ export class AzService extends Context.Service<
141
141
  message: result.stderr || `Command failed with exit code ${result.exitCode}`,
142
142
  command: fullCommand,
143
143
  exitCode: result.exitCode,
144
- stderr: result.stderr || undefined,
144
+ ...(result.stderr ? { stderr: result.stderr } : {}),
145
145
  });
146
146
  }
147
147
 
@@ -209,7 +209,7 @@ export class AzService extends Context.Service<
209
209
  message: result.stderr || `Invoke failed with exit code ${result.exitCode}`,
210
210
  command: fullCommand,
211
211
  exitCode: result.exitCode,
212
- stderr: result.stderr || undefined,
212
+ ...(result.stderr ? { stderr: result.stderr } : {}),
213
213
  });
214
214
  }
215
215
 
@@ -28,14 +28,6 @@ export class DbTunnelError extends Schema.TaggedErrorClass<DbTunnelError>()("DbT
28
28
  retryable: Schema.optionalKey(Schema.Boolean),
29
29
  }) {}
30
30
 
31
- export class DbParseError extends Schema.TaggedErrorClass<DbParseError>()("DbParseError", {
32
- message: Schema.String,
33
- rawOutput: Schema.String,
34
- hint: Schema.optionalKey(Schema.String),
35
- nextCommand: Schema.optionalKey(Schema.String),
36
- retryable: Schema.optionalKey(Schema.Boolean),
37
- }) {}
38
-
39
31
  export class DbMutationBlockedError extends Schema.TaggedErrorClass<DbMutationBlockedError>()(
40
32
  "DbMutationBlockedError",
41
33
  {
@@ -47,9 +39,4 @@ export class DbMutationBlockedError extends Schema.TaggedErrorClass<DbMutationBl
47
39
  },
48
40
  ) {}
49
41
 
50
- export type DbError =
51
- | DbConnectionError
52
- | DbMutationBlockedError
53
- | DbParseError
54
- | DbQueryError
55
- | DbTunnelError;
42
+ export type DbError = DbConnectionError | DbMutationBlockedError | DbQueryError | DbTunnelError;
@@ -17,6 +17,7 @@ import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#confi
17
17
  import { DbConfigService, makeDbConfigLayer } from "./config-service";
18
18
  import { DbConnectionError } from "./errors";
19
19
  import { DbService } from "./service";
20
+ import { DbSqlClient } from "./sql-client";
20
21
 
21
22
  // Extract --profile from argv before @effect/cli parsing
22
23
  // so we can build the correct config layer.
@@ -243,6 +244,7 @@ const dbConfigLayer = makeDbConfigLayer(profileArg);
243
244
  const MainLayer = DbService.layer.pipe(
244
245
  // provideMerge (not provide) so DbConfigService stays in the program context for the `envs` command,
245
246
  // which reads it directly rather than going through DbService.
247
+ Layer.provideMerge(DbSqlClient.layer),
246
248
  Layer.provideMerge(dbConfigLayer),
247
249
  Layer.provideMerge(ConfigServiceLayer),
248
250
  Layer.provideMerge(BunServices.layer),
@@ -25,9 +25,27 @@ const MUTATION_TARGET_PATTERNS: Array<[DbMutationOperation, RegExp]> = [
25
25
  ["delete", new RegExp(String.raw`^\s*DELETE\s+FROM\s+(${QUALIFIED_IDENTIFIER_PATTERN})`, "i")],
26
26
  ];
27
27
 
28
+ const DOLLAR_QUOTE_TAG = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/;
29
+
30
+ /**
31
+ * Postgres treats a backslash as an escape only inside an E'...' literal; in a standard string
32
+ * it is an ordinary character, so applying escapes everywhere would swallow a real closing quote.
33
+ */
34
+ function isEscapeStringPrefix(sql: string, quoteIndex: number): boolean {
35
+ const prefix = sql[quoteIndex - 1];
36
+ if (prefix !== "E" && prefix !== "e") return false;
37
+ const before = sql[quoteIndex - 2];
38
+ return before === undefined || !/[A-Za-z0-9_$]/.test(before);
39
+ }
40
+
28
41
  /**
29
- * Strip SQL comments (block and line) while preserving string literals.
30
- * This prevents bypass via inline comment masking before DELETE statements.
42
+ * Strip SQL comments (block and line) and empty out every string literal, keeping only
43
+ * double-quoted identifiers verbatim because the mutation-target parser needs them.
44
+ * Single quotes, double-quoted identifiers and dollar-quoted bodies must all be tracked:
45
+ * a quote or comment token surviving inside a region another check reads differently is a
46
+ * guard bypass. `SELECT * FROM "weird--table"; DROP TABLE users` would lose its statement
47
+ * separator to a phantom comment, and `SELECT $$O\'Brien$$ AS n; DROP TABLE users` would
48
+ * leave an unpaired apostrophe that swallows the separator instead.
31
49
  */
32
50
  export function stripSqlComments(sql: string): string {
33
51
  let result = "";
@@ -38,38 +56,63 @@ export function stripSqlComments(sql: string): string {
38
56
  const ch = sql[i];
39
57
  const next = i + 1 < len ? sql[i + 1] : "";
40
58
 
41
- // Single-quoted string literal — skip through
42
- if (ch === "'") {
59
+ if (ch === "'" || ch === '"') {
60
+ const keepBody = ch === '"';
61
+ const backslashEscapes = ch === "'" && isEscapeStringPrefix(sql, i);
43
62
  result += ch;
44
63
  i++;
45
64
  while (i < len) {
46
- if (sql[i] === "'" && i + 1 < len && sql[i + 1] === "'") {
47
- result += "''";
65
+ if (backslashEscapes && sql[i] === "\\" && i + 1 < len) {
66
+ if (keepBody) result += sql[i] + sql[i + 1];
48
67
  i += 2;
49
- } else if (sql[i] === "'") {
50
- result += "'";
68
+ } else if (sql[i] === ch && sql[i + 1] === ch) {
69
+ if (keepBody) result += ch + ch;
70
+ i += 2;
71
+ } else if (sql[i] === ch) {
72
+ result += ch;
51
73
  i++;
52
74
  break;
53
75
  } else {
54
- result += sql[i];
76
+ if (keepBody) result += sql[i];
55
77
  i++;
56
78
  }
57
79
  }
58
80
  continue;
59
81
  }
60
82
 
61
- // Block comment /* ... */
83
+ if (ch === "$") {
84
+ const tag = DOLLAR_QUOTE_TAG.exec(sql.slice(i))?.[0];
85
+ if (tag) {
86
+ const closing = sql.indexOf(tag, i + tag.length);
87
+ const stop = closing === -1 ? len : closing + tag.length;
88
+ result += closing === -1 ? tag : tag + tag;
89
+ i = stop;
90
+ continue;
91
+ }
92
+ }
93
+
94
+ // Postgres block comments nest, so track depth rather than stopping at the first */.
62
95
  if (ch === "/" && next === "*") {
96
+ let depth = 1;
63
97
  i += 2;
64
- while (i + 1 < len && !(sql[i] === "*" && sql[i + 1] === "/")) {
65
- i++;
98
+ while (i + 1 < len && depth > 0) {
99
+ if (sql[i] === "/" && sql[i + 1] === "*") {
100
+ depth++;
101
+ i += 2;
102
+ } else if (sql[i] === "*" && sql[i + 1] === "/") {
103
+ depth--;
104
+ i += 2;
105
+ } else {
106
+ i++;
107
+ }
108
+ }
109
+ if (depth > 0) {
110
+ i = len;
66
111
  }
67
- i += 2; // skip closing */
68
- result += " "; // replace comment with space
112
+ result += " ";
69
113
  continue;
70
114
  }
71
115
 
72
- // Line comment -- ...
73
116
  if (ch === "-" && next === "-") {
74
117
  i += 2;
75
118
  while (i < len && sql[i] !== "\n") {
@@ -86,6 +129,44 @@ export function stripSqlComments(sql: string): string {
86
129
  return result;
87
130
  }
88
131
 
132
+ /**
133
+ * Postgres runs every statement in a simple-query batch, but MUTATION_PATTERNS only match the
134
+ * first one, so `select 1; delete from x` would otherwise pass the guard as a plain read.
135
+ * Dollar-quoted bodies containing `;` are rejected as well: this fails closed by design.
136
+ */
137
+ export function hasMultipleStatements(sql: string): boolean {
138
+ const stripped = stripSqlComments(sql);
139
+ let index = 0;
140
+
141
+ while (index < stripped.length) {
142
+ const char = stripped[index];
143
+
144
+ if (char === "'" || char === '"') {
145
+ index++;
146
+ while (index < stripped.length) {
147
+ if (stripped[index] === char && stripped[index + 1] === char) {
148
+ index += 2;
149
+ continue;
150
+ }
151
+ if (stripped[index] === char) {
152
+ index++;
153
+ break;
154
+ }
155
+ index++;
156
+ }
157
+ continue;
158
+ }
159
+
160
+ if (char === ";" && stripped.slice(index + 1).trim().length > 0) {
161
+ return true;
162
+ }
163
+
164
+ index++;
165
+ }
166
+
167
+ return false;
168
+ }
169
+
89
170
  export function isMutationQuery(sql: string): boolean {
90
171
  const stripped = stripSqlComments(sql);
91
172
  return MUTATION_PATTERNS.some((pattern) => pattern.test(stripped));
@@ -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,11 +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";
14
+ import { DbSqlClient, type DbConnection } from "./sql-client";
13
15
  import {
14
16
  DbConnectionError,
15
17
  DbMutationBlockedError,
16
- DbParseError,
17
18
  DbQueryError,
18
19
  DbTunnelError,
19
20
  type DbError,
@@ -29,6 +30,7 @@ import {
29
30
  detectSchemaError,
30
31
  getAllowedMutationOperation,
31
32
  getMutationTarget,
33
+ hasMultipleStatements,
32
34
  isValidTableName,
33
35
  isMutationQuery,
34
36
  } from "./security";
@@ -86,6 +88,7 @@ export class DbService extends Context.Service<
86
88
  Effect.scoped(
87
89
  Effect.gen(function* () {
88
90
  const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
91
+ const sqlClient = yield* DbSqlClient;
89
92
  const agentToolsConfig = yield* ConfigService;
90
93
  const dbConfig = yield* DbConfigService;
91
94
 
@@ -230,22 +233,29 @@ export class DbService extends Context.Service<
230
233
  return { stdout, stderr, exitCode };
231
234
  }),
232
235
  ).pipe(
233
- Effect.mapError(
234
- (platformError) =>
235
- new DbQueryError({
236
- message: `Command execution failed: ${String(platformError)}`,
237
- sql: "shell command",
238
- stderr: undefined,
239
- }),
240
- ),
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
+ }),
241
245
  );
242
246
 
243
247
  const checkPortOpen = (port: number) =>
244
- executeShellCommand(
245
- ChildProcess.make("nc", ["-z", "localhost", String(port)], {
246
- stdout: "pipe",
247
- stderr: "pipe",
248
- }),
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),
249
259
  );
250
260
 
251
261
  const runWithVpnPrerequisites = <E>(
@@ -282,11 +292,7 @@ export class DbService extends Context.Service<
282
292
  return false;
283
293
  }
284
294
 
285
- const result = yield* checkPortOpen(port).pipe(
286
- Effect.catch(() => Effect.succeed({ exitCode: 1 })),
287
- );
288
-
289
- if (result.exitCode === 0) {
295
+ if (yield* checkPortOpen(port)) {
290
296
  return true;
291
297
  }
292
298
 
@@ -376,68 +382,34 @@ export class DbService extends Context.Service<
376
382
  return result.exitCode === 0;
377
383
  });
378
384
 
379
- const buildPsqlCommand = (
380
- config: DbConfig,
381
- sql: string,
382
- password: string,
383
- useTuplesOnly: boolean,
384
- ) => {
385
- const args = [
386
- "-h",
387
- config.host,
388
- "-p",
389
- String(config.port),
390
- "-U",
391
- config.user,
392
- "-d",
393
- config.database,
394
- ];
395
-
396
- const commandArgs = useTuplesOnly
397
- ? [...args, "-t", "-A", "-c", sql]
398
- : [...args, "-c", sql];
399
-
400
- return ChildProcess.make("psql", commandArgs, {
401
- stdout: "pipe",
402
- stderr: "pipe",
403
- env: {
404
- ...process.env,
405
- ...(password ? { PGPASSWORD: password } : {}),
406
- ...(isFullyReadOnly(config)
407
- ? { PGOPTIONS: "-c default_transaction_read_only=on" }
408
- : {}),
409
- } as Record<string, string>,
410
- });
411
- };
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
+ });
412
393
 
413
- const fetchTableNamesForError = Effect.fn("DbService.fetchTableNamesForError")(function* (
414
- config: DbConfig,
415
- password: string,
416
- ) {
417
- const command = buildPsqlCommand(
418
- config,
419
- `SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL}) ORDER BY schemaname, tablename;`,
420
- password,
421
- true,
422
- );
423
- const result = yield* executeShellCommand(command).pipe(
424
- Effect.catch(() =>
425
- Effect.succeed({
426
- stdout: "",
427
- stderr: "",
428
- exitCode: 1,
429
- }),
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),
430
403
  ),
404
+ Effect.orElseSucceed(() => [] as string[]),
431
405
  );
432
- if (result.exitCode !== 0) {
433
- return [] as string[];
434
- }
435
406
 
436
- return result.stdout
437
- .trim()
438
- .split("\n")
439
- .filter((name) => name.length > 0);
440
- });
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
+ );
441
413
 
442
414
  const fetchColumnNamesForError = Effect.fn("DbService.fetchColumnNamesForError")(function* (
443
415
  config: DbConfig,
@@ -455,29 +427,11 @@ export class DbService extends Context.Service<
455
427
  ? `AND table_schema = '${escapedSchemaName}'`
456
428
  : `AND table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
457
429
 
458
- const command = buildPsqlCommand(
430
+ return yield* fetchSingleColumn(
459
431
  config,
460
- `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
461
432
  password,
462
- true,
463
- );
464
- const result = yield* executeShellCommand(command).pipe(
465
- Effect.catch(() =>
466
- Effect.succeed({
467
- stdout: "",
468
- stderr: "",
469
- exitCode: 1,
470
- }),
471
- ),
433
+ `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
472
434
  );
473
- if (result.exitCode !== 0) {
474
- return [] as string[];
475
- }
476
-
477
- return result.stdout
478
- .trim()
479
- .split("\n")
480
- .filter((name) => name.length > 0);
481
435
  });
482
436
 
483
437
  const executeSelectQuery = Effect.fn("DbService.executeSelectQuery")(function* (
@@ -489,16 +443,15 @@ export class DbService extends Context.Service<
489
443
  limit?: number,
490
444
  ) {
491
445
  const selectSql = sql.trim().replace(/;\s*$/, "");
492
- const wrappedSql = `SELECT json_agg(t) FROM (${selectSql}) t;`;
493
- const command = buildPsqlCommand(config, wrappedSql, password, true);
494
- const result = yield* executeShellCommand(command);
446
+ const outcome = yield* Effect.result(runSql(config, password, selectSql));
495
447
  const endTime = yield* Clock.currentTimeMillis;
496
448
 
497
- if (result.exitCode !== 0) {
498
- const schemaError = detectSchemaError(result.stderr, sql);
449
+ if (Result.isFailure(outcome)) {
450
+ const failureMessage = outcome.failure.message.trim();
451
+ const schemaError = detectSchemaError(failureMessage, sql);
499
452
  const baseResult: QueryResult = {
500
453
  success: false,
501
- error: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
454
+ error: failureMessage || "Query failed",
502
455
  executionTimeMs: Number(endTime) - startTimeMs,
503
456
  };
504
457
 
@@ -524,32 +477,10 @@ export class DbService extends Context.Service<
524
477
  };
525
478
  }
526
479
 
527
- return yield* new DbQueryError({
528
- message: baseResult.error ?? "Query failed",
529
- sql,
530
- stderr: result.stderr.trim() || undefined,
531
- });
532
- }
533
-
534
- const trimmedOutput = result.stdout.trim();
535
- if (!trimmedOutput || trimmedOutput === "null") {
536
- return {
537
- success: true,
538
- data: [],
539
- rowCount: 0,
540
- executionTimeMs: Number(endTime) - startTimeMs,
541
- };
480
+ return yield* outcome.failure;
542
481
  }
543
482
 
544
- const rawData = yield* Effect.try({
545
- try: () => JSON.parse(trimmedOutput) as Record<string, unknown>[],
546
- catch: () =>
547
- new DbParseError({
548
- message: "Failed to parse query result as JSON.",
549
- rawOutput: trimmedOutput.slice(0, 500),
550
- }),
551
- });
552
-
483
+ const rawData = outcome.success.rows;
553
484
  const transformed = applyTransform
554
485
  ? transformQueryResult(rawData, limit)
555
486
  : {
@@ -574,25 +505,13 @@ export class DbService extends Context.Service<
574
505
  password: string,
575
506
  startTimeMs: number,
576
507
  ) {
577
- const command = buildPsqlCommand(config, sql, password, false);
578
- const result = yield* executeShellCommand(command);
508
+ const outcome = yield* runSql(config, password, sql);
579
509
  const endTime = yield* Clock.currentTimeMillis;
580
-
581
- if (result.exitCode !== 0) {
582
- return yield* new DbQueryError({
583
- message: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
584
- sql,
585
- stderr: result.stderr.trim() || undefined,
586
- });
587
- }
588
-
589
- const output = result.stdout.trim();
590
- const rowCountMatch = output.match(/(?:UPDATE|DELETE|INSERT \d+)\s+(\d+)/i);
591
- const rowCount = rowCountMatch ? parseInt(rowCountMatch[1], 10) : 0;
510
+ const rowCount = outcome.rowCount;
592
511
 
593
512
  return {
594
513
  success: true,
595
- message: output,
514
+ message: `${outcome.command} ${rowCount}`.trim(),
596
515
  rowCount,
597
516
  executionTimeMs: Number(endTime) - startTimeMs,
598
517
  };
@@ -745,6 +664,15 @@ export class DbService extends Context.Service<
745
664
  const startTimeMs = yield* Clock.currentTimeMillis;
746
665
  const resolvedConfig = yield* resolveDbConfig(config, env);
747
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
+
748
676
  const mutation = isMutationQuery(sql);
749
677
  const mutationOperation = mutation ? getAllowedMutationOperation(sql) : undefined;
750
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
+ }
@@ -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,
@@ -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: undefined,
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: undefined,
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");
File without changes
File without changes
@@ -0,0 +1,42 @@
1
+ export const REQUIRED_BINARIES = {
2
+ az: {
3
+ tools: "az-tool",
4
+ install:
5
+ "Install it with `brew install azure-cli` (macOS) or see https://learn.microsoft.com/cli/azure/install-azure-cli.",
6
+ },
7
+ gh: {
8
+ tools: "gh-tool",
9
+ install:
10
+ "Install it with `brew install gh` (macOS) or see https://cli.github.com/, then run `gh auth login`.",
11
+ },
12
+ git: {
13
+ tools: "gh-tool release and stack commands",
14
+ install:
15
+ "Install it with `xcode-select --install` (macOS) or see https://git-scm.com/downloads.",
16
+ },
17
+ kubectl: {
18
+ tools: "k8s-tool and db-tool tunnels",
19
+ install:
20
+ "Install it with `brew install kubectl` (macOS) or see https://kubernetes.io/docs/tasks/tools/.",
21
+ },
22
+ } as const;
23
+
24
+ export type RequiredBinary = keyof typeof REQUIRED_BINARIES;
25
+
26
+ export type MissingBinary = { binary: RequiredBinary; message: string; hint: string };
27
+
28
+ const isRequiredBinary = (binary: string): binary is RequiredBinary => binary in REQUIRED_BINARIES;
29
+
30
+ export const describeMissingBinary = (binary: RequiredBinary): MissingBinary => ({
31
+ binary,
32
+ message: `Required binary "${binary}" was not found on PATH.`,
33
+ hint: `${binary} is needed by ${REQUIRED_BINARIES[binary].tools}. ${REQUIRED_BINARIES[binary].install}`,
34
+ });
35
+
36
+ export const missingBinaryFromSpawnFailure = (
37
+ binary: string,
38
+ failure: string,
39
+ ): MissingBinary | undefined =>
40
+ isRequiredBinary(binary) && failure.includes("NotFound")
41
+ ? describeMissingBinary(binary)
42
+ : undefined;
@@ -1,4 +1,11 @@
1
- export type { BaseResult, CommandOptions, CommandResult, Environment, OutputFormat } from "./types";
1
+ export type {
2
+ BaseResult,
3
+ CommandOptions,
4
+ CommandResult,
5
+ Environment,
6
+ OutputFormat,
7
+ ToolResult,
8
+ } from "./types";
2
9
 
3
10
  export { formatAny, formatOption, formatOutput, logFormatted } from "./format";
4
11
 
@@ -0,0 +1,12 @@
1
+ export { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
2
+
3
+ export { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
4
+
5
+ export { isPrerequisiteRunError, PrerequisiteRunError } from "#shared/prerequisites/errors";
6
+
7
+ export type {
8
+ PrerequisiteCommandResult,
9
+ PrerequisiteCommandRunner,
10
+ } from "#shared/prerequisites/types";
11
+
12
+ export type { ProfilePrerequisites, VpnPrerequisite } from "#config/types";
@@ -10,6 +10,12 @@ export type BaseResult = {
10
10
  hint?: string;
11
11
  };
12
12
 
13
+ // Widening BaseResult itself is not honest: db-tool's select branch emits no message.
14
+ export type ToolResult = BaseResult & {
15
+ message: string;
16
+ data?: unknown;
17
+ };
18
+
13
19
  export type CommandOptions = {
14
20
  cwd?: string;
15
21
  env?: Record<string, string>;