@blogic-cz/agent-tools 0.1.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.
- package/LICENSE +21 -0
- package/README.md +236 -0
- package/package.json +70 -0
- package/schemas/agent-tools.schema.json +319 -0
- package/src/az-tool/build.ts +295 -0
- package/src/az-tool/config.ts +33 -0
- package/src/az-tool/errors.ts +26 -0
- package/src/az-tool/extract-option-value.ts +12 -0
- package/src/az-tool/index.ts +181 -0
- package/src/az-tool/security.ts +130 -0
- package/src/az-tool/service.ts +292 -0
- package/src/az-tool/types.ts +67 -0
- package/src/config/index.ts +12 -0
- package/src/config/loader.ts +170 -0
- package/src/config/types.ts +82 -0
- package/src/credential-guard/claude-hook.ts +28 -0
- package/src/credential-guard/index.ts +435 -0
- package/src/db-tool/config-service.ts +38 -0
- package/src/db-tool/errors.ts +40 -0
- package/src/db-tool/index.ts +91 -0
- package/src/db-tool/schema.ts +69 -0
- package/src/db-tool/security.ts +116 -0
- package/src/db-tool/service.ts +605 -0
- package/src/db-tool/types.ts +33 -0
- package/src/gh-tool/config.ts +7 -0
- package/src/gh-tool/errors.ts +47 -0
- package/src/gh-tool/index.ts +140 -0
- package/src/gh-tool/issue.ts +361 -0
- package/src/gh-tool/pr/commands.ts +432 -0
- package/src/gh-tool/pr/core.ts +497 -0
- package/src/gh-tool/pr/helpers.ts +84 -0
- package/src/gh-tool/pr/index.ts +19 -0
- package/src/gh-tool/pr/review.ts +571 -0
- package/src/gh-tool/repo.ts +147 -0
- package/src/gh-tool/service.ts +192 -0
- package/src/gh-tool/types.ts +97 -0
- package/src/gh-tool/workflow.ts +542 -0
- package/src/index.ts +1 -0
- package/src/k8s-tool/errors.ts +21 -0
- package/src/k8s-tool/index.ts +151 -0
- package/src/k8s-tool/service.ts +227 -0
- package/src/k8s-tool/types.ts +9 -0
- package/src/logs-tool/errors.ts +29 -0
- package/src/logs-tool/index.ts +176 -0
- package/src/logs-tool/service.ts +323 -0
- package/src/logs-tool/types.ts +40 -0
- package/src/session-tool/config.ts +55 -0
- package/src/session-tool/errors.ts +38 -0
- package/src/session-tool/index.ts +270 -0
- package/src/session-tool/service.ts +210 -0
- package/src/session-tool/types.ts +28 -0
- package/src/shared/bun.ts +59 -0
- package/src/shared/cli.ts +38 -0
- package/src/shared/error-renderer.ts +42 -0
- package/src/shared/exec.ts +62 -0
- package/src/shared/format.ts +27 -0
- package/src/shared/index.ts +16 -0
- package/src/shared/throttle.ts +35 -0
- package/src/shared/types.ts +25 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
|
+
import { Clock, Duration, Effect, Layer, Ref, ServiceMap, Stream } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { DbConfig, QueryResult, SchemaMode } from "./types";
|
|
5
|
+
|
|
6
|
+
import { DbConfigService, DbConfigServiceLayer, TUNNEL_CHECK_INTERVAL_MS } from "./config-service";
|
|
7
|
+
import {
|
|
8
|
+
DbConnectionError,
|
|
9
|
+
DbMutationBlockedError,
|
|
10
|
+
DbParseError,
|
|
11
|
+
DbQueryError,
|
|
12
|
+
DbTunnelError,
|
|
13
|
+
type DbError,
|
|
14
|
+
} from "./errors";
|
|
15
|
+
import { getColumns, getRelationships, getTableNames } from "./schema";
|
|
16
|
+
import { detectSchemaError, isValidTableName, isMutationQuery } from "./security";
|
|
17
|
+
|
|
18
|
+
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
19
|
+
|
|
20
|
+
export class DbService extends ServiceMap.Service<
|
|
21
|
+
DbService,
|
|
22
|
+
{
|
|
23
|
+
readonly executeQuery: (env: string, sql: string) => Effect.Effect<QueryResult, DbError>;
|
|
24
|
+
readonly executeSchemaQuery: (
|
|
25
|
+
env: string,
|
|
26
|
+
mode: SchemaMode,
|
|
27
|
+
table?: string,
|
|
28
|
+
) => Effect.Effect<QueryResult, DbError>;
|
|
29
|
+
}
|
|
30
|
+
>()("@agent-tools/DbService") {
|
|
31
|
+
static readonly layer = Layer.effect(
|
|
32
|
+
DbService,
|
|
33
|
+
Effect.scoped(
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
36
|
+
const dbConfig = yield* DbConfigService;
|
|
37
|
+
|
|
38
|
+
if (!dbConfig) {
|
|
39
|
+
const noConfigError = (env: string) =>
|
|
40
|
+
new DbConnectionError({
|
|
41
|
+
message:
|
|
42
|
+
"No database configuration found. Add a 'database' section to agent-tools.json5.",
|
|
43
|
+
environment: env,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
executeQuery: (env: string, _sql: string) => Effect.fail(noConfigError(env)),
|
|
47
|
+
executeSchemaQuery: (env: string, _mode: SchemaMode, _table?: string) =>
|
|
48
|
+
Effect.fail(noConfigError(env)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const kubectlContext = dbConfig.kubectl?.context;
|
|
53
|
+
const kubectlNamespace = dbConfig.kubectl?.namespace;
|
|
54
|
+
const tunnelTimeoutMs = dbConfig.tunnelTimeoutMs ?? 5000;
|
|
55
|
+
const remotePort = dbConfig.remotePort ?? 5432;
|
|
56
|
+
|
|
57
|
+
const zshrcEnvCache = yield* Ref.make<Record<string, string> | null>(null);
|
|
58
|
+
|
|
59
|
+
const loadEnvFromZshrc = Effect.fn("DbService.loadEnvFromZshrc")(function* () {
|
|
60
|
+
const cached = yield* Ref.get(zshrcEnvCache);
|
|
61
|
+
if (cached !== null) {
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const home = process.env.HOME;
|
|
66
|
+
if (!home || home.trim() === "") {
|
|
67
|
+
yield* Ref.set(zshrcEnvCache, {});
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const zshrcPath = `${home}/.zshrc`;
|
|
72
|
+
const content = yield* Effect.tryPromise(async () => {
|
|
73
|
+
const file = Bun.file(zshrcPath);
|
|
74
|
+
if (!(await file.exists())) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
return await file.text();
|
|
78
|
+
}).pipe(Effect.orElseSucceed(() => ""));
|
|
79
|
+
|
|
80
|
+
const envVars: Record<string, string> = {};
|
|
81
|
+
const regex = /^export\s+([A-Z_][A-Z0-9_]*)=["']?([^"'\n]+)["']?/gm;
|
|
82
|
+
let match: RegExpExecArray | null;
|
|
83
|
+
|
|
84
|
+
while ((match = regex.exec(content)) !== null) {
|
|
85
|
+
envVars[match[1]] = match[2];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
yield* Ref.set(zshrcEnvCache, envVars);
|
|
89
|
+
return envVars;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const resolvePassword = Effect.fn("DbService.resolvePassword")(function* (
|
|
93
|
+
config: DbConfig,
|
|
94
|
+
env: string,
|
|
95
|
+
) {
|
|
96
|
+
if (config.password) {
|
|
97
|
+
return config.password;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (config.passwordEnvVar) {
|
|
101
|
+
const fromEnv = process.env[config.passwordEnvVar];
|
|
102
|
+
if (fromEnv) {
|
|
103
|
+
return fromEnv;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const zshrcEnv = yield* loadEnvFromZshrc();
|
|
107
|
+
const fromZsh = zshrcEnv[config.passwordEnvVar];
|
|
108
|
+
if (fromZsh) {
|
|
109
|
+
return fromZsh;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return yield* new DbConnectionError({
|
|
113
|
+
message: `Environment variable ${config.passwordEnvVar} is not set.`,
|
|
114
|
+
environment: env,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Local databases typically don't need a password
|
|
119
|
+
return "";
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const executeShellCommand = (command: ChildProcess.Command) =>
|
|
123
|
+
Effect.scoped(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const proc = yield* executor.spawn(command);
|
|
126
|
+
|
|
127
|
+
const stdoutChunk = yield* proc.stdout.pipe(Stream.decodeText(), Stream.runCollect);
|
|
128
|
+
const stderrChunk = yield* proc.stderr.pipe(Stream.decodeText(), Stream.runCollect);
|
|
129
|
+
|
|
130
|
+
const stdout = stdoutChunk.join("");
|
|
131
|
+
const stderr = stderrChunk.join("");
|
|
132
|
+
const exitCode = yield* proc.exitCode;
|
|
133
|
+
|
|
134
|
+
return { stdout, stderr, exitCode };
|
|
135
|
+
}),
|
|
136
|
+
).pipe(
|
|
137
|
+
Effect.mapError(
|
|
138
|
+
(platformError) =>
|
|
139
|
+
new DbQueryError({
|
|
140
|
+
message: `Command execution failed: ${String(platformError)}`,
|
|
141
|
+
sql: "shell command",
|
|
142
|
+
stderr: undefined,
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const checkPortOpen = (port: number) =>
|
|
148
|
+
executeShellCommand(
|
|
149
|
+
ChildProcess.make("nc", ["-z", "localhost", String(port)], {
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "pipe",
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const waitForPort = (port: number, timeoutMs: number, intervalMs: number) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const startTime = yield* Clock.currentTimeMillis;
|
|
158
|
+
const deadline = Number(startTime) + timeoutMs;
|
|
159
|
+
|
|
160
|
+
while (true) {
|
|
161
|
+
const now = yield* Clock.currentTimeMillis;
|
|
162
|
+
if (Number(now) >= deadline) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const result = yield* checkPortOpen(port).pipe(
|
|
167
|
+
Effect.catch(() => Effect.succeed({ exitCode: 1 })),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (result.exitCode === 0) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
yield* Effect.sleep(Duration.millis(intervalMs));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const startTunnelProcess = (config: DbConfig) =>
|
|
179
|
+
Effect.gen(function* () {
|
|
180
|
+
if (!kubectlContext || !kubectlNamespace) {
|
|
181
|
+
return yield* Effect.fail(
|
|
182
|
+
new DbTunnelError({
|
|
183
|
+
message:
|
|
184
|
+
"kubectl context and namespace are required for tunneling. Add kubectl config to agent-tools.json5 database section.",
|
|
185
|
+
port: config.port,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const proc = yield* executor.spawn(
|
|
191
|
+
ChildProcess.make(
|
|
192
|
+
"kubectl",
|
|
193
|
+
[
|
|
194
|
+
"port-forward",
|
|
195
|
+
"--context",
|
|
196
|
+
kubectlContext,
|
|
197
|
+
"--namespace",
|
|
198
|
+
kubectlNamespace,
|
|
199
|
+
"svc/postgresql",
|
|
200
|
+
`${config.port}:${remotePort}`,
|
|
201
|
+
],
|
|
202
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return proc;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const buildPsqlCommand = (
|
|
210
|
+
config: DbConfig,
|
|
211
|
+
sql: string,
|
|
212
|
+
password: string,
|
|
213
|
+
useTuplesOnly: boolean,
|
|
214
|
+
) => {
|
|
215
|
+
const args = [
|
|
216
|
+
"-h",
|
|
217
|
+
"localhost",
|
|
218
|
+
"-p",
|
|
219
|
+
String(config.port),
|
|
220
|
+
"-U",
|
|
221
|
+
config.user,
|
|
222
|
+
"-d",
|
|
223
|
+
config.database,
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const commandArgs = useTuplesOnly
|
|
227
|
+
? [...args, "-t", "-A", "-c", sql]
|
|
228
|
+
: [...args, "-c", sql];
|
|
229
|
+
|
|
230
|
+
return ChildProcess.make("psql", commandArgs, {
|
|
231
|
+
stdout: "pipe",
|
|
232
|
+
stderr: "pipe",
|
|
233
|
+
env: {
|
|
234
|
+
...process.env,
|
|
235
|
+
...(password ? { PGPASSWORD: password } : {}),
|
|
236
|
+
} as Record<string, string>,
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const fetchTableNamesForError = Effect.fn("DbService.fetchTableNamesForError")(function* (
|
|
241
|
+
config: DbConfig,
|
|
242
|
+
password: string,
|
|
243
|
+
) {
|
|
244
|
+
const command = buildPsqlCommand(
|
|
245
|
+
config,
|
|
246
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;",
|
|
247
|
+
password,
|
|
248
|
+
true,
|
|
249
|
+
);
|
|
250
|
+
const result = yield* executeShellCommand(command).pipe(
|
|
251
|
+
Effect.catch(() =>
|
|
252
|
+
Effect.succeed({
|
|
253
|
+
stdout: "",
|
|
254
|
+
stderr: "",
|
|
255
|
+
exitCode: 1,
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
if (result.exitCode !== 0) {
|
|
260
|
+
return [] as string[];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result.stdout
|
|
264
|
+
.trim()
|
|
265
|
+
.split("\n")
|
|
266
|
+
.filter((name) => name.length > 0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const fetchColumnNamesForError = Effect.fn("DbService.fetchColumnNamesForError")(function* (
|
|
270
|
+
config: DbConfig,
|
|
271
|
+
password: string,
|
|
272
|
+
tableName: string,
|
|
273
|
+
) {
|
|
274
|
+
if (!isValidTableName(tableName)) {
|
|
275
|
+
return [] as string[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const escapedTableName = tableName.replaceAll("'", "''");
|
|
279
|
+
|
|
280
|
+
const command = buildPsqlCommand(
|
|
281
|
+
config,
|
|
282
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' AND table_schema = 'public' ORDER BY ordinal_position;`,
|
|
283
|
+
password,
|
|
284
|
+
true,
|
|
285
|
+
);
|
|
286
|
+
const result = yield* executeShellCommand(command).pipe(
|
|
287
|
+
Effect.catch(() =>
|
|
288
|
+
Effect.succeed({
|
|
289
|
+
stdout: "",
|
|
290
|
+
stderr: "",
|
|
291
|
+
exitCode: 1,
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
if (result.exitCode !== 0) {
|
|
296
|
+
return [] as string[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return result.stdout
|
|
300
|
+
.trim()
|
|
301
|
+
.split("\n")
|
|
302
|
+
.filter((name) => name.length > 0);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const executeSelectQuery = Effect.fn("DbService.executeSelectQuery")(function* (
|
|
306
|
+
config: DbConfig,
|
|
307
|
+
sql: string,
|
|
308
|
+
password: string,
|
|
309
|
+
startTimeMs: number,
|
|
310
|
+
) {
|
|
311
|
+
const wrappedSql = `SELECT json_agg(t) FROM (${sql}) t;`;
|
|
312
|
+
const command = buildPsqlCommand(config, wrappedSql, password, true);
|
|
313
|
+
const result = yield* executeShellCommand(command);
|
|
314
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
315
|
+
|
|
316
|
+
if (result.exitCode !== 0) {
|
|
317
|
+
const schemaError = detectSchemaError(result.stderr, sql);
|
|
318
|
+
const baseResult: QueryResult = {
|
|
319
|
+
success: false,
|
|
320
|
+
error: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
|
|
321
|
+
executionTimeMs: Number(endTime) - startTimeMs,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (schemaError.type === "table_not_found") {
|
|
325
|
+
const availableTables = yield* fetchTableNamesForError(config, password);
|
|
326
|
+
return {
|
|
327
|
+
...baseResult,
|
|
328
|
+
availableTables,
|
|
329
|
+
hint: `Table "${schemaError.missingName}" not found. Use one of the availableTables listed above.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (schemaError.type === "column_not_found" && schemaError.tableName) {
|
|
334
|
+
const availableColumns = yield* fetchColumnNamesForError(
|
|
335
|
+
config,
|
|
336
|
+
password,
|
|
337
|
+
schemaError.tableName,
|
|
338
|
+
);
|
|
339
|
+
return {
|
|
340
|
+
...baseResult,
|
|
341
|
+
availableColumns,
|
|
342
|
+
hint: `Column "${schemaError.missingName}" not found in table "${schemaError.tableName}". Use one of the availableColumns listed above.`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return yield* new DbQueryError({
|
|
347
|
+
message: baseResult.error ?? "Query failed",
|
|
348
|
+
sql,
|
|
349
|
+
stderr: result.stderr.trim() || undefined,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const trimmedOutput = result.stdout.trim();
|
|
354
|
+
if (!trimmedOutput || trimmedOutput === "null") {
|
|
355
|
+
return {
|
|
356
|
+
success: true,
|
|
357
|
+
data: [],
|
|
358
|
+
rowCount: 0,
|
|
359
|
+
executionTimeMs: Number(endTime) - startTimeMs,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const data = yield* Effect.try({
|
|
364
|
+
try: () => JSON.parse(trimmedOutput) as Record<string, unknown>[],
|
|
365
|
+
catch: () =>
|
|
366
|
+
new DbParseError({
|
|
367
|
+
message: "Failed to parse query result as JSON.",
|
|
368
|
+
rawOutput: trimmedOutput.slice(0, 500),
|
|
369
|
+
}),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
success: true,
|
|
374
|
+
data,
|
|
375
|
+
rowCount: data.length,
|
|
376
|
+
executionTimeMs: Number(endTime) - startTimeMs,
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const executeMutationQuery = Effect.fn("DbService.executeMutationQuery")(function* (
|
|
381
|
+
config: DbConfig,
|
|
382
|
+
sql: string,
|
|
383
|
+
password: string,
|
|
384
|
+
startTimeMs: number,
|
|
385
|
+
) {
|
|
386
|
+
const command = buildPsqlCommand(config, sql, password, false);
|
|
387
|
+
const result = yield* executeShellCommand(command);
|
|
388
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
389
|
+
|
|
390
|
+
if (result.exitCode !== 0) {
|
|
391
|
+
return yield* new DbQueryError({
|
|
392
|
+
message: result.stderr.trim() || `psql exited with code ${result.exitCode}`,
|
|
393
|
+
sql,
|
|
394
|
+
stderr: result.stderr.trim() || undefined,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const output = result.stdout.trim();
|
|
399
|
+
const rowCountMatch = output.match(/(?:UPDATE|DELETE|INSERT \d+)\s+(\d+)/i);
|
|
400
|
+
const rowCount = rowCountMatch ? parseInt(rowCountMatch[1], 10) : 0;
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
success: true,
|
|
404
|
+
message: output,
|
|
405
|
+
rowCount,
|
|
406
|
+
executionTimeMs: Number(endTime) - startTimeMs,
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const executeFullSchemaQuery = Effect.fn("DbService.executeFullSchemaQuery")(function* (
|
|
411
|
+
config: DbConfig,
|
|
412
|
+
password: string,
|
|
413
|
+
startTimeMs: number,
|
|
414
|
+
) {
|
|
415
|
+
const tablesResult = yield* executeSelectQuery(
|
|
416
|
+
config,
|
|
417
|
+
getTableNames(),
|
|
418
|
+
password,
|
|
419
|
+
startTimeMs,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
if (!tablesResult.success || !tablesResult.data) {
|
|
423
|
+
return tablesResult;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const tables = tablesResult.data as {
|
|
427
|
+
name: string;
|
|
428
|
+
}[];
|
|
429
|
+
const fullSchema: Record<string, unknown>[] = [];
|
|
430
|
+
|
|
431
|
+
for (const table of tables) {
|
|
432
|
+
const columnsResult = yield* executeSelectQuery(
|
|
433
|
+
config,
|
|
434
|
+
getColumns(table.name),
|
|
435
|
+
password,
|
|
436
|
+
startTimeMs,
|
|
437
|
+
).pipe(Effect.catch(() => Effect.succeed(null)));
|
|
438
|
+
|
|
439
|
+
if (columnsResult && columnsResult.success && columnsResult.data) {
|
|
440
|
+
fullSchema.push({
|
|
441
|
+
table: table.name,
|
|
442
|
+
columns: columnsResult.data,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
success: true,
|
|
451
|
+
data: fullSchema,
|
|
452
|
+
rowCount: fullSchema.length,
|
|
453
|
+
message: `Full schema: ${fullSchema.length} tables`,
|
|
454
|
+
executionTimeMs: Number(endTime) - startTimeMs,
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const runQueryWithOptionalTunnel = <E>(
|
|
459
|
+
config: DbConfig,
|
|
460
|
+
queryEffect: Effect.Effect<QueryResult, E>,
|
|
461
|
+
): Effect.Effect<QueryResult, E | DbTunnelError> => {
|
|
462
|
+
if (!config.needsTunnel) {
|
|
463
|
+
return queryEffect;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return Effect.scoped(
|
|
467
|
+
Effect.gen(function* () {
|
|
468
|
+
const tunnelProc = yield* startTunnelProcess(config).pipe(
|
|
469
|
+
Effect.mapError(
|
|
470
|
+
(platformError) =>
|
|
471
|
+
new DbTunnelError({
|
|
472
|
+
message: `Failed to start tunnel: ${String(platformError)}`,
|
|
473
|
+
port: config.port,
|
|
474
|
+
}),
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const ready = yield* waitForPort(
|
|
479
|
+
config.port,
|
|
480
|
+
tunnelTimeoutMs,
|
|
481
|
+
TUNNEL_CHECK_INTERVAL_MS,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
if (!ready) {
|
|
485
|
+
yield* tunnelProc.kill().pipe(Effect.ignore);
|
|
486
|
+
return yield* new DbTunnelError({
|
|
487
|
+
message: "Tunnel failed to open within timeout.",
|
|
488
|
+
port: config.port,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const result = yield* queryEffect.pipe(
|
|
493
|
+
Effect.ensuring(tunnelProc.kill().pipe(Effect.ignore)),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
return result;
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const getConfigForEnv = (env: string): DbConfig => {
|
|
502
|
+
const envConfig = dbConfig.environments[env];
|
|
503
|
+
if (!envConfig) {
|
|
504
|
+
const available = Object.keys(dbConfig.environments).join(", ");
|
|
505
|
+
throw new Error(`Unknown environment "${env}". Available: ${available}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const isLocal = LOCALHOST_HOSTS.has(envConfig.host);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
user: envConfig.user,
|
|
512
|
+
database: envConfig.database,
|
|
513
|
+
passwordEnvVar: envConfig.passwordEnvVar,
|
|
514
|
+
port: envConfig.port,
|
|
515
|
+
needsTunnel: !isLocal && dbConfig.kubectl !== undefined,
|
|
516
|
+
allowMutations: isLocal,
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const executeQuery = Effect.fn("DbService.executeQuery")(function* (
|
|
521
|
+
env: string,
|
|
522
|
+
sql: string,
|
|
523
|
+
) {
|
|
524
|
+
const config = getConfigForEnv(env);
|
|
525
|
+
const startTimeMs = yield* Clock.currentTimeMillis;
|
|
526
|
+
const password = yield* resolvePassword(config, env);
|
|
527
|
+
const mutation = isMutationQuery(sql);
|
|
528
|
+
|
|
529
|
+
if (mutation && !config.allowMutations) {
|
|
530
|
+
return yield* new DbMutationBlockedError({
|
|
531
|
+
message:
|
|
532
|
+
"Mutation queries (UPDATE, INSERT, DELETE, etc.) are not allowed on this environment. Use a local environment for mutations.",
|
|
533
|
+
environment: env,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const queryEffect = mutation
|
|
538
|
+
? executeMutationQuery(config, sql, password, Number(startTimeMs))
|
|
539
|
+
: executeSelectQuery(config, sql, password, Number(startTimeMs));
|
|
540
|
+
|
|
541
|
+
return yield* runQueryWithOptionalTunnel(config, queryEffect);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const executeSchemaQuery = Effect.fn("DbService.executeSchemaQuery")(function* (
|
|
545
|
+
env: string,
|
|
546
|
+
mode: SchemaMode,
|
|
547
|
+
table?: string,
|
|
548
|
+
) {
|
|
549
|
+
const config = getConfigForEnv(env);
|
|
550
|
+
const startTimeMs = yield* Clock.currentTimeMillis;
|
|
551
|
+
const password = yield* resolvePassword(config, env);
|
|
552
|
+
|
|
553
|
+
if (mode === "columns" && !table) {
|
|
554
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
555
|
+
return {
|
|
556
|
+
success: false,
|
|
557
|
+
error: "--schema columns requires --table <name>",
|
|
558
|
+
executionTimeMs: Number(endTime) - Number(startTimeMs),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (mode === "columns" && table) {
|
|
563
|
+
if (!isValidTableName(table)) {
|
|
564
|
+
const endTime = yield* Clock.currentTimeMillis;
|
|
565
|
+
return {
|
|
566
|
+
success: false,
|
|
567
|
+
error:
|
|
568
|
+
"Invalid table name. Use only letters, numbers, and underscores, and start with a letter or underscore.",
|
|
569
|
+
executionTimeMs: Number(endTime) - Number(startTimeMs),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const queryEffect =
|
|
575
|
+
mode === "tables"
|
|
576
|
+
? executeSelectQuery(config, getTableNames(), password, Number(startTimeMs))
|
|
577
|
+
: mode === "columns"
|
|
578
|
+
? executeSelectQuery(config, getColumns(table ?? ""), password, Number(startTimeMs))
|
|
579
|
+
: mode === "relationships"
|
|
580
|
+
? executeSelectQuery(config, getRelationships(), password, Number(startTimeMs))
|
|
581
|
+
: executeFullSchemaQuery(config, password, Number(startTimeMs));
|
|
582
|
+
|
|
583
|
+
const result = yield* runQueryWithOptionalTunnel(config, queryEffect);
|
|
584
|
+
|
|
585
|
+
if (result.success) {
|
|
586
|
+
const descriptor =
|
|
587
|
+
mode === "columns" && table
|
|
588
|
+
? `Schema introspection: ${mode} for table '${table}'`
|
|
589
|
+
: `Schema introspection: ${mode}`;
|
|
590
|
+
return {
|
|
591
|
+
...result,
|
|
592
|
+
message: descriptor,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return result;
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return { executeQuery, executeSchemaQuery };
|
|
600
|
+
}),
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export const DbServiceLayer = DbService.layer.pipe(Layer.provide(DbConfigServiceLayer));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Environment, OutputFormat } from "../shared";
|
|
2
|
+
export type { Environment, OutputFormat };
|
|
3
|
+
|
|
4
|
+
export type SchemaMode = "tables" | "columns" | "full" | "relationships";
|
|
5
|
+
|
|
6
|
+
export type DbConfig = {
|
|
7
|
+
user: string;
|
|
8
|
+
database: string;
|
|
9
|
+
password?: string;
|
|
10
|
+
passwordEnvVar?: string;
|
|
11
|
+
port: number;
|
|
12
|
+
needsTunnel: boolean;
|
|
13
|
+
allowMutations: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type QueryResult = {
|
|
17
|
+
success: boolean;
|
|
18
|
+
data?: Record<string, unknown>[];
|
|
19
|
+
message?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
rowCount?: number;
|
|
22
|
+
executionTimeMs: number;
|
|
23
|
+
availableTables?: string[];
|
|
24
|
+
availableColumns?: string[];
|
|
25
|
+
hint?: string;
|
|
26
|
+
schemaFile?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SchemaErrorInfo = {
|
|
30
|
+
type: "table_not_found" | "column_not_found" | null;
|
|
31
|
+
missingName: string | null;
|
|
32
|
+
tableName: string | null;
|
|
33
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const DEFAULT_MERGE_STRATEGY = "squash" as const;
|
|
2
|
+
export const DEFAULT_DELETE_BRANCH = true as const;
|
|
3
|
+
export const CI_CHECK_WATCH_TIMEOUT_MS = 600_000 as const; // 10 minutes
|
|
4
|
+
export const GRAPHQL_PAGE_SIZE = 100 as const;
|
|
5
|
+
export const GH_BINARY = "gh" as const;
|
|
6
|
+
|
|
7
|
+
export const MERGE_STRATEGIES = ["squash", "merge", "rebase"] as const;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export class GitHubCommandError extends Schema.TaggedErrorClass<GitHubCommandError>()(
|
|
4
|
+
"GitHubCommandError",
|
|
5
|
+
{
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
command: Schema.String,
|
|
8
|
+
exitCode: Schema.Number,
|
|
9
|
+
stderr: Schema.String,
|
|
10
|
+
},
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
export class GitHubNotFoundError extends Schema.TaggedErrorClass<GitHubNotFoundError>()(
|
|
14
|
+
"GitHubNotFoundError",
|
|
15
|
+
{
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
identifier: Schema.String,
|
|
18
|
+
resource: Schema.String,
|
|
19
|
+
},
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
export class GitHubAuthError extends Schema.TaggedErrorClass<GitHubAuthError>()("GitHubAuthError", {
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
export class GitHubMergeError extends Schema.TaggedErrorClass<GitHubMergeError>()(
|
|
27
|
+
"GitHubMergeError",
|
|
28
|
+
{
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
reason: Schema.Literals(["conflicts", "checks_failing", "branch_protected", "unknown"]),
|
|
31
|
+
},
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
export class GitHubTimeoutError extends Schema.TaggedErrorClass<GitHubTimeoutError>()(
|
|
35
|
+
"GitHubTimeoutError",
|
|
36
|
+
{
|
|
37
|
+
message: Schema.String,
|
|
38
|
+
timeoutMs: Schema.Number,
|
|
39
|
+
},
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
export type GitHubServiceError =
|
|
43
|
+
| GitHubCommandError
|
|
44
|
+
| GitHubNotFoundError
|
|
45
|
+
| GitHubAuthError
|
|
46
|
+
| GitHubMergeError
|
|
47
|
+
| GitHubTimeoutError;
|