@blogic-cz/agent-tools 0.14.10 → 0.14.11

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.10",
3
+ "version": "0.14.11",
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",
@@ -5,18 +5,39 @@
5
5
  * This is a known limitation — callers must validate the table name
6
6
  * via `isValidTableName()` before calling `getColumns()`.
7
7
  */
8
+ export const SYSTEM_SCHEMAS_SQL = "'pg_catalog', 'information_schema'";
9
+
10
+ export function parseTableReference(tableName: string): { schemaName?: string; tableName: string } {
11
+ const [schemaName, name, ...extra] = tableName.split(".");
12
+
13
+ if (name && extra.length === 0) {
14
+ return { schemaName, tableName: name };
15
+ }
16
+
17
+ return { tableName };
18
+ }
19
+
8
20
  export const SCHEMA_QUERIES = {
9
21
  tables: `
10
- SELECT tablename as name
22
+ SELECT
23
+ schemaname as schema,
24
+ tablename as name,
25
+ schemaname || '.' || tablename as qualified_name
11
26
  FROM pg_tables
12
- WHERE schemaname = 'public'
13
- ORDER BY tablename
27
+ WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL})
28
+ ORDER BY schemaname, tablename
14
29
  `,
15
30
  columns: (tableName: string) => {
16
- const escapedTableName = tableName.replaceAll("'", "''");
31
+ const tableReference = parseTableReference(tableName);
32
+ const escapedTableName = tableReference.tableName.replaceAll("'", "''");
33
+ const schemaFilter = tableReference.schemaName
34
+ ? `AND c.table_schema = '${tableReference.schemaName.replaceAll("'", "''")}'`
35
+ : `AND c.table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
17
36
 
18
37
  return `
19
38
  SELECT
39
+ c.table_schema as schema,
40
+ c.table_name as table,
20
41
  c.column_name as name,
21
42
  c.data_type as type,
22
43
  c.is_nullable = 'YES' as nullable,
@@ -32,14 +53,16 @@ export const SCHEMA_QUERIES = {
32
53
  ) as is_primary_key
33
54
  FROM information_schema.columns c
34
55
  WHERE c.table_name = '${escapedTableName}'
35
- AND c.table_schema = 'public'
36
- ORDER BY c.ordinal_position
56
+ ${schemaFilter}
57
+ ORDER BY c.table_schema, c.ordinal_position
37
58
  `;
38
59
  },
39
60
  relationships: `
40
61
  SELECT
62
+ tc.table_schema as from_schema,
41
63
  tc.table_name as from_table,
42
64
  kcu.column_name as from_column,
65
+ ccu.table_schema as to_schema,
43
66
  ccu.table_name as to_table,
44
67
  ccu.column_name as to_column,
45
68
  tc.constraint_name
@@ -49,10 +72,10 @@ export const SCHEMA_QUERIES = {
49
72
  AND tc.table_schema = kcu.table_schema
50
73
  JOIN information_schema.constraint_column_usage ccu
51
74
  ON ccu.constraint_name = tc.constraint_name
52
- AND ccu.table_schema = tc.table_schema
75
+ AND ccu.constraint_schema = tc.constraint_schema
53
76
  WHERE tc.constraint_type = 'FOREIGN KEY'
54
- AND tc.table_schema = 'public'
55
- ORDER BY tc.table_name, kcu.column_name
77
+ AND tc.table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})
78
+ ORDER BY tc.table_schema, tc.table_name, kcu.column_name
56
79
  `,
57
80
  };
58
81
 
@@ -10,7 +10,7 @@ const MUTATION_PATTERNS = [
10
10
  /^\s*CREATE\s+/i,
11
11
  ];
12
12
 
13
- const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
13
+ const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
14
14
 
15
15
  /**
16
16
  * Strip SQL comments (block and line) while preserving string literals.
@@ -15,7 +15,13 @@ import {
15
15
  DbTunnelError,
16
16
  type DbError,
17
17
  } from "./errors";
18
- import { getColumns, getRelationships, getTableNames } from "./schema";
18
+ import {
19
+ getColumns,
20
+ getRelationships,
21
+ getTableNames,
22
+ parseTableReference,
23
+ SYSTEM_SCHEMAS_SQL,
24
+ } from "./schema";
19
25
  import { detectSchemaError, isValidTableName, isMutationQuery } from "./security";
20
26
  import { transformQueryResult } from "./transformers";
21
27
 
@@ -324,7 +330,7 @@ export class DbService extends Context.Service<
324
330
  ) {
325
331
  const command = buildPsqlCommand(
326
332
  config,
327
- "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;",
333
+ `SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL}) ORDER BY schemaname, tablename;`,
328
334
  password,
329
335
  true,
330
336
  );
@@ -356,11 +362,16 @@ export class DbService extends Context.Service<
356
362
  return [] as string[];
357
363
  }
358
364
 
359
- const escapedTableName = tableName.replaceAll("'", "''");
365
+ const tableReference = parseTableReference(tableName);
366
+ const escapedSchemaName = tableReference.schemaName?.replaceAll("'", "''");
367
+ const escapedTableName = tableReference.tableName.replaceAll("'", "''");
368
+ const schemaFilter = escapedSchemaName
369
+ ? `AND table_schema = '${escapedSchemaName}'`
370
+ : `AND table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
360
371
 
361
372
  const command = buildPsqlCommand(
362
373
  config,
363
- `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' AND table_schema = 'public' ORDER BY ordinal_position;`,
374
+ `SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
364
375
  password,
365
376
  true,
366
377
  );
@@ -516,21 +527,25 @@ export class DbService extends Context.Service<
516
527
  }
517
528
 
518
529
  const tables = tablesResult.data as {
530
+ schema?: string;
519
531
  name: string;
532
+ qualified_name?: string;
520
533
  }[];
521
534
  const fullSchema: Record<string, unknown>[] = [];
522
535
 
523
536
  for (const table of tables) {
524
537
  const columnsResult = yield* executeSelectQuery(
525
538
  config,
526
- getColumns(table.name),
539
+ getColumns(table.qualified_name ?? table.name),
527
540
  password,
528
541
  startTimeMs,
529
542
  ).pipe(Effect.catch(() => Effect.succeed(null)));
530
543
 
531
544
  if (columnsResult && columnsResult.success && columnsResult.data) {
532
545
  fullSchema.push({
546
+ schema: table.schema,
533
547
  table: table.name,
548
+ qualified_name: table.qualified_name ?? table.name,
534
549
  columns: columnsResult.data,
535
550
  });
536
551
  }
@@ -668,7 +683,7 @@ export class DbService extends Context.Service<
668
683
  return {
669
684
  success: false,
670
685
  error:
671
- "Invalid table name. Use only letters, numbers, and underscores, and start with a letter or underscore.",
686
+ "Invalid table name. Use only letters, numbers, underscores, and an optional schema prefix, and start each identifier with a letter or underscore.",
672
687
  executionTimeMs: Number(endTime) - Number(startTimeMs),
673
688
  };
674
689
  }
@@ -10,7 +10,6 @@ import { AuditServiceLayer, withAudit } from "#shared/audit";
10
10
  import { VERSION } from "#shared";
11
11
 
12
12
  import { metricsCommand } from "./metrics";
13
- import { logsCommand } from "./logs";
14
13
  import { traceCommand } from "./trace";
15
14
 
16
15
  const renderCauseToStderr = (cause: Cause.Cause<unknown>) => Console.error(cause.toString());
@@ -19,7 +18,7 @@ const mainCommand = Command.make("observability-tool", {}).pipe(
19
18
  Command.withDescription(
20
19
  "LGTM observability queries — Tempo traces, Loki logs, Prometheus metrics",
21
20
  ),
22
- Command.withSubcommands([traceCommand, metricsCommand, logsCommand]),
21
+ Command.withSubcommands([traceCommand, metricsCommand]),
23
22
  );
24
23
 
25
24
  const cli = Command.run(mainCommand, { version: VERSION });
@@ -13,7 +13,6 @@ import {
13
13
  profileOption,
14
14
  resolveConfig,
15
15
  } from "./shared";
16
- import { extractLogsFromDsQuery } from "./logs";
17
16
  import type {
18
17
  FlattenedSpan,
19
18
  ObservabilityEnvConfig,
@@ -222,6 +221,18 @@ function summarizeTrace(traceId: string, spans: readonly FlattenedSpan[]): Trace
222
221
  };
223
222
  }
224
223
 
224
+ function parseLabel(value: string | Record<string, string>): Record<string, string> {
225
+ if (typeof value === "object" && value !== null) {
226
+ return value;
227
+ }
228
+
229
+ try {
230
+ return JSON.parse(value) as Record<string, string>;
231
+ } catch {
232
+ return {};
233
+ }
234
+ }
235
+
225
236
  type ResolvedTrace = {
226
237
  readonly resolution: SpanResolution;
227
238
  readonly spans: FlattenedSpan[];
@@ -471,7 +482,34 @@ const logsCommand = Command.make(
471
482
  });
472
483
  }
473
484
 
474
- const logs = extractLogsFromDsQuery(response);
485
+ const logs: Array<{ timestamp: string; line: string; labels: Record<string, string> }> = [];
486
+ for (const frame of response.results.A.frames ?? []) {
487
+ const fields = frame.schema.fields;
488
+ const values = frame.data.values;
489
+ const timeIndex = fields.findIndex(
490
+ (field) => field.name === "timestamp" || field.type === "time",
491
+ );
492
+ const lineIndex = fields.findIndex(
493
+ (field) => field.name === "body" || field.name === "Line" || field.name === "line",
494
+ );
495
+ const labelsIndex = fields.findIndex(
496
+ (field) => field.name === "labels" || field.name === "labelTypes",
497
+ );
498
+
499
+ const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
500
+ const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
501
+ const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
502
+ string | Record<string, string>
503
+ >;
504
+
505
+ for (const [index, line] of lines.entries()) {
506
+ logs.push({
507
+ timestamp: String(timestamps[index] ?? ""),
508
+ line,
509
+ labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
510
+ });
511
+ }
512
+ }
475
513
 
476
514
  const result = {
477
515
  success: true,
@@ -1,185 +0,0 @@
1
- import { Console, Effect } from "effect";
2
- import { Argument, Command, Flag } from "effect/unstable/cli";
3
-
4
- import { formatOption, formatOutput } from "#shared";
5
-
6
- import { ObservabilityToolError } from "./errors";
7
- import {
8
- envOption,
9
- formatObservabilityError,
10
- observabilityDsQuery,
11
- profileOption,
12
- resolveConfig,
13
- } from "./shared";
14
-
15
- type LogLine = {
16
- readonly timestamp: string;
17
- readonly line: string;
18
- readonly body?: string;
19
- readonly severity?: string;
20
- readonly attributes?: Record<string, unknown>;
21
- readonly labels: Record<string, string>;
22
- };
23
-
24
- type StructuredLogLine = {
25
- readonly body?: string;
26
- readonly severity?: string;
27
- readonly attributes?: Record<string, unknown>;
28
- };
29
-
30
- function parseLabel(value: string | Record<string, string>): Record<string, string> {
31
- if (typeof value === "object" && value !== null) {
32
- return value;
33
- }
34
-
35
- try {
36
- return JSON.parse(value) as Record<string, string>;
37
- } catch {
38
- return {};
39
- }
40
- }
41
-
42
- function parseStructuredLogLine(line: string): StructuredLogLine {
43
- try {
44
- const parsed = JSON.parse(line) as unknown;
45
- if (typeof parsed !== "object" || parsed === null) {
46
- return {};
47
- }
48
-
49
- const record = parsed as Record<string, unknown>;
50
- return {
51
- body: typeof record.body === "string" ? record.body : undefined,
52
- severity: typeof record.severity === "string" ? record.severity : undefined,
53
- attributes:
54
- typeof record.attributes === "object" && record.attributes !== null
55
- ? (record.attributes as Record<string, unknown>)
56
- : undefined,
57
- };
58
- } catch {
59
- return {};
60
- }
61
- }
62
-
63
- export function extractLogsFromDsQuery(response: {
64
- results: {
65
- A: {
66
- frames?: Array<{
67
- schema: { fields: Array<{ name: string; type?: string }> };
68
- data: { values: unknown[][] };
69
- }>;
70
- };
71
- };
72
- }): LogLine[] {
73
- const logs: LogLine[] = [];
74
-
75
- for (const frame of response.results.A.frames ?? []) {
76
- const fields = frame.schema.fields;
77
- const values = frame.data.values;
78
- const timeIndex = fields.findIndex(
79
- (field) => field.name === "timestamp" || field.name === "Time" || field.type === "time",
80
- );
81
- const lineIndex = fields.findIndex(
82
- (field) => field.name === "body" || field.name === "Line" || field.name === "line",
83
- );
84
- const labelsIndex = fields.findIndex(
85
- (field) => field.name === "labels" || field.name === "labelTypes",
86
- );
87
-
88
- const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
89
- const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
90
- const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
91
- string | Record<string, string>
92
- >;
93
-
94
- for (const [index, line] of lines.entries()) {
95
- const structured = parseStructuredLogLine(line);
96
- logs.push({
97
- timestamp: String(timestamps[index] ?? ""),
98
- line,
99
- ...structured,
100
- labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
101
- });
102
- }
103
- }
104
-
105
- return logs;
106
- }
107
-
108
- const queryCommand = Command.make(
109
- "query",
110
- {
111
- logql: Argument.string("logql"),
112
- format: formatOption,
113
- env: envOption,
114
- profile: profileOption,
115
- start: Flag.string("start").pipe(
116
- Flag.withDescription("Start time (default: now-1h)"),
117
- Flag.withDefault("now-1h"),
118
- ),
119
- end: Flag.string("end").pipe(
120
- Flag.withDescription("End time (default: now)"),
121
- Flag.withDefault("now"),
122
- ),
123
- limit: Flag.integer("limit").pipe(
124
- Flag.withDescription("Max log lines (default: 100)"),
125
- Flag.withDefault(100),
126
- ),
127
- },
128
- ({ logql, format, env, profile, start, end, limit }) => {
129
- const startedAt = Date.now();
130
-
131
- return Effect.gen(function* () {
132
- const config = yield* resolveConfig(env, profile);
133
- const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
134
- from: start,
135
- to: end,
136
- maxLines: limit,
137
- });
138
-
139
- if (response.results.A.error) {
140
- return yield* new ObservabilityToolError({ cause: new Error(response.results.A.error) });
141
- }
142
-
143
- const logs = extractLogsFromDsQuery(response).toSorted((left, right) =>
144
- right.timestamp.localeCompare(left.timestamp),
145
- );
146
-
147
- const result = {
148
- success: true,
149
- message: `Found ${logs.length} Loki log line(s)`,
150
- data: {
151
- environment: env,
152
- grafanaUrl: config.url,
153
- lokiDatasourceUid: config.lokiUid,
154
- query: logql,
155
- start,
156
- end,
157
- limit,
158
- logCount: logs.length,
159
- logs,
160
- },
161
- executionTimeMs: Date.now() - startedAt,
162
- };
163
-
164
- yield* Console.log(formatOutput(result, format));
165
- }).pipe(
166
- Effect.catch((error) =>
167
- Effect.gen(function* () {
168
- const result = {
169
- success: false,
170
- message: "Failed to execute LogQL query",
171
- error: formatObservabilityError(error),
172
- hint: "Check LogQL syntax and Grafana/Loki connectivity",
173
- executionTimeMs: Date.now() - startedAt,
174
- };
175
- yield* Console.log(formatOutput(result, format));
176
- }),
177
- ),
178
- );
179
- },
180
- ).pipe(Command.withDescription("Execute LogQL range query via Grafana/Loki"));
181
-
182
- export const logsCommand = Command.make("logs", {}).pipe(
183
- Command.withDescription("Loki log operations via Grafana"),
184
- Command.withSubcommands([queryCommand]),
185
- );