@blogic-cz/agent-tools 0.15.13 → 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 +21 -73
- package/src/audit-tool/index.ts +0 -0
- package/src/az-tool/service.ts +2 -2
- package/src/db-tool/errors.ts +1 -14
- package/src/db-tool/index.ts +2 -0
- package/src/db-tool/security.ts +96 -15
- package/src/db-tool/service.ts +71 -146
- package/src/db-tool/sql-client.ts +80 -0
- package/src/gh-tool/pr/helpers.ts +11 -9
- package/src/index.ts +2 -0
- package/src/k8s-tool/service.ts +33 -28
- package/src/observability-tool/index.ts +0 -0
- package/src/session-tool/index.ts +0 -0
- package/src/shared/binary-preflight.ts +42 -0
- package/src/shared/index.ts +8 -1
- package/src/shared/prerequisites/index.ts +12 -0
- package/src/shared/types.ts +6 -0
- package/src/db-tool/psql.ts +0 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "0.15.
|
|
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": "./
|
|
47
|
+
"types": "./src/index.ts",
|
|
49
48
|
"imports": {
|
|
50
|
-
"#shared":
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"#
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"./
|
|
105
|
-
|
|
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.
|
|
105
|
+
"bun": ">=1.3.0"
|
|
158
106
|
}
|
|
159
107
|
}
|
package/src/audit-tool/index.ts
CHANGED
|
File without changes
|
package/src/az-tool/service.ts
CHANGED
|
@@ -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
|
|
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
|
|
212
|
+
...(result.stderr ? { stderr: result.stderr } : {}),
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
215
|
|
package/src/db-tool/errors.ts
CHANGED
|
@@ -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;
|
package/src/db-tool/index.ts
CHANGED
|
@@ -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),
|
package/src/db-tool/security.ts
CHANGED
|
@@ -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)
|
|
30
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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] === "
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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));
|
package/src/db-tool/service.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
417
|
-
config
|
|
418
|
-
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
501
|
-
const
|
|
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:
|
|
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*
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
package/src/k8s-tool/service.ts
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
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;
|
package/src/shared/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export type {
|
|
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";
|
package/src/shared/types.ts
CHANGED
|
@@ -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>;
|
package/src/db-tool/psql.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
// Homebrew keeps libpq keg-only, so psql is routinely installed yet absent from PATH.
|
|
5
|
-
const KEG_ONLY_PSQL_DIRECTORIES = ["/opt/homebrew/opt/libpq/bin", "/usr/local/opt/libpq/bin"];
|
|
6
|
-
|
|
7
|
-
export const PSQL_MISSING_HINT =
|
|
8
|
-
"psql was not found on PATH. On macOS install it with `brew install libpq`; libpq is keg-only, so also add /opt/homebrew/opt/libpq/bin (Apple silicon) or /usr/local/opt/libpq/bin (Intel) to PATH.";
|
|
9
|
-
|
|
10
|
-
export const resolvePsqlSearchPath = (
|
|
11
|
-
pathEnv: string | undefined,
|
|
12
|
-
fileExists: (candidate: string) => boolean = existsSync,
|
|
13
|
-
): string | undefined => {
|
|
14
|
-
const directories = (pathEnv ?? "").split(":").filter((directory) => directory.length > 0);
|
|
15
|
-
if (directories.some((directory) => fileExists(join(directory, "psql")))) {
|
|
16
|
-
return pathEnv;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const kegOnly = KEG_ONLY_PSQL_DIRECTORIES.find((directory) =>
|
|
20
|
-
fileExists(join(directory, "psql")),
|
|
21
|
-
);
|
|
22
|
-
if (kegOnly === undefined) {
|
|
23
|
-
return pathEnv;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return directories.length > 0 ? [...directories, kegOnly].join(":") : kegOnly;
|
|
27
|
-
};
|