planche 0.4.1 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +24 -13
  2. package/dist/index.js +1418 -1125
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,584 +8,532 @@ import { parseArgs } from "util";
8
8
 
9
9
  // src/server.ts
10
10
  import express from "express";
11
- import { timingSafeEqual } from "crypto";
11
+ import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
12
12
 
13
13
  // ../shared/contract.ts
14
14
  var PROTOCOL_VERSION = 2;
15
15
 
16
- // src/tunneling.ts
17
- import { randomUUID } from "crypto";
16
+ // src/ssh-tunnel.ts
17
+ import { createServer } from "net";
18
+ import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
19
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
20
+ import { homedir as homedir2 } from "os";
21
+ import { basename as basename2, join as join2 } from "path";
22
+ import { Client } from "ssh2";
18
23
 
19
- // src/drivers/mysql.ts
20
- import mysql from "mysql2/promise";
21
- var MYSQL_TYPES = {
22
- 0: "decimal",
23
- 1: "tinyint",
24
- 2: "smallint",
25
- 3: "int",
26
- 4: "float",
27
- 5: "double",
28
- 6: "null",
29
- 7: "timestamp",
30
- 8: "bigint",
31
- 9: "mediumint",
32
- 10: "date",
33
- 11: "time",
34
- 12: "datetime",
35
- 13: "year",
36
- 14: "newdate",
37
- 15: "varchar",
38
- 16: "bit",
39
- 245: "json",
40
- 246: "newdecimal",
41
- 247: "enum",
42
- 248: "set",
43
- 249: "tinyblob",
44
- 250: "mediumblob",
45
- 251: "longblob",
46
- 252: "blob",
47
- 253: "varstring",
48
- 254: "string",
49
- 255: "geometry"
50
- };
51
- function getTypeName(typeId) {
52
- return MYSQL_TYPES[typeId] ?? "unknown";
24
+ // src/ssh-config.ts
25
+ import { readFile, readdir } from "fs/promises";
26
+ import { homedir } from "os";
27
+ import { dirname, isAbsolute, join, basename } from "path";
28
+ var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
29
+ var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
30
+ var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
31
+ (f) => join(homedir(), ".ssh", f)
32
+ );
33
+ function expandHome(path) {
34
+ return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
53
35
  }
54
- function toConnectionOptions(config) {
55
- return {
56
- host: config.host,
57
- port: config.port,
58
- user: config.user,
59
- password: config.password,
60
- database: config.database || void 0,
61
- dateStrings: true,
62
- charset: config.charset || "utf8mb4"
63
- };
36
+ function globToRegExp(pattern) {
37
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
38
+ return new RegExp(`^${escaped}$`, "i");
64
39
  }
65
- var USE_STATEMENT = /^\s*USE\s/i;
66
- var MySqlDriver = class {
67
- constructor(connection, config) {
68
- this.connection = connection;
69
- this.label = `${config.user}@${config.host}:${config.port}`;
70
- this.currentDatabase = config.database ?? null;
71
- }
72
- connection;
73
- type = "mysql";
74
- label;
75
- /**
76
- * Default schema the session is currently in, or null when unknown (after
77
- * the client ran its own `USE`). Tracked so the schema is switched with a
78
- * plain `USE` only when it actually changes: `changeUser()` would reset the
79
- * whole session on every call, silently rolling back open transactions and
80
- * dropping temporary tables and user variables.
81
- */
82
- currentDatabase;
83
- async query(sql2, database) {
84
- if (database && database !== this.currentDatabase) {
85
- await this.connection.query(`USE ${mysql.escapeId(database)}`);
86
- this.currentDatabase = database;
87
- }
88
- const [results, fields] = await this.connection.query(sql2);
89
- if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
90
- if (Array.isArray(results) && fields) {
91
- const fieldPackets = fields;
92
- const mappedFields = fieldPackets.map((field) => ({
93
- name: field.name,
94
- orgName: field.orgName ?? field.name,
95
- type: getTypeName(field.type ?? 253),
96
- table: field.table ?? "",
97
- orgTable: field.orgTable ?? "",
98
- db: field.db ?? "",
99
- maxLength: field.length ?? 0
100
- }));
101
- const records = results.map(
102
- (row) => fieldPackets.map((field) => row[field.name])
103
- );
104
- return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
105
- }
106
- const header = results;
107
- return {
108
- fields: [],
109
- records: [],
110
- affectedRows: header.affectedRows ?? 0,
111
- insertId: header.insertId ?? 0,
112
- isResultQuery: false
113
- };
114
- }
115
- async close() {
116
- await this.connection.end();
117
- }
118
- };
119
- var mysqlDriverFactory = {
120
- async open(config) {
121
- const connection = await mysql.createConnection(toConnectionOptions(config));
122
- return new MySqlDriver(connection, config);
123
- },
124
- async test(config) {
125
- const connection = await mysql.createConnection({
126
- ...toConnectionOptions(config),
127
- database: void 0
128
- });
129
- await connection.end();
130
- },
131
- parseConfig(body) {
132
- const host = typeof body.host === "string" ? body.host.trim() : "";
133
- const user = typeof body.user === "string" ? body.user : "";
134
- if (!host || !user) {
135
- throw new Error("host and user are required");
136
- }
137
- return {
138
- driver: "mysql",
139
- host,
140
- port: Number(body.port) || 3306,
141
- user,
142
- password: typeof body.password === "string" ? body.password : "",
143
- database: typeof body.database === "string" && body.database ? body.database : void 0,
144
- charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
145
- };
40
+ function hostPatternMatches(patterns, host) {
41
+ let matched = false;
42
+ for (const raw of patterns) {
43
+ const negated = raw.startsWith("!");
44
+ const pattern = negated ? raw.slice(1) : raw;
45
+ if (!globToRegExp(pattern).test(host)) continue;
46
+ if (negated) return false;
47
+ matched = true;
146
48
  }
147
- };
148
-
149
- // src/drivers/postgres.ts
150
- import pg from "pg";
151
- var TEXT_OIDS = [
152
- 20,
153
- // int8
154
- 1700,
155
- // numeric
156
- 1082,
157
- // date
158
- 1114,
159
- // timestamp
160
- 1184,
161
- // timestamptz
162
- 1083,
163
- // time
164
- 1266,
165
- // timetz
166
- 1186
167
- // interval
168
- ];
169
- var types = new pg.TypeOverrides();
170
- for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
171
- var BUILTIN_TYPES = {
172
- 16: "bool",
173
- 17: "bytea",
174
- 18: "char",
175
- 19: "name",
176
- 20: "int8",
177
- 21: "int2",
178
- 23: "int4",
179
- 25: "text",
180
- 26: "oid",
181
- 114: "json",
182
- 142: "xml",
183
- 700: "float4",
184
- 701: "float8",
185
- 1042: "bpchar",
186
- 1043: "varchar",
187
- 1082: "date",
188
- 1083: "time",
189
- 1114: "timestamp",
190
- 1184: "timestamptz",
191
- 1186: "interval",
192
- 1266: "timetz",
193
- 1700: "numeric",
194
- 2950: "uuid",
195
- 3802: "jsonb",
196
- 1560: "bit",
197
- 1562: "varbit",
198
- 600: "point",
199
- 869: "inet",
200
- 650: "cidr",
201
- 829: "macaddr",
202
- 1007: "_int4",
203
- 1009: "_text"
204
- };
205
- function toClientConfig(config) {
206
- return {
207
- host: config.host,
208
- port: config.port,
209
- user: config.user,
210
- password: config.password,
211
- database: config.database,
212
- ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
213
- types
214
- };
49
+ return matched;
215
50
  }
216
- var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
217
- var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
218
- var PostgresDriver = class {
219
- constructor(client, config) {
220
- this.client = client;
221
- this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
51
+ function tokenize(line) {
52
+ const tokens = [];
53
+ const re = /"([^"]*)"|(\S+)/g;
54
+ let m;
55
+ while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
56
+ return tokens;
57
+ }
58
+ function parseLine(line) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed || trimmed.startsWith("#")) return void 0;
61
+ const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
62
+ const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
63
+ return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
64
+ }
65
+ async function expandGlob(path) {
66
+ const base = basename(path);
67
+ if (!/[*?]/.test(base)) return [path];
68
+ const dir = dirname(path);
69
+ try {
70
+ const re = globToRegExp(base);
71
+ return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
72
+ } catch {
73
+ return [];
222
74
  }
223
- client;
224
- type = "postgres";
225
- label;
226
- /**
227
- * Schema currently first in `search_path`, or null when unknown (after the
228
- * client set it itself). The app's "database" for a Postgres connection is
229
- * a schema of the bound database, switched with `SET search_path`.
230
- */
231
- currentSchema = null;
232
- typeNames = { ...BUILTIN_TYPES };
233
- /**
234
- * Result fields name their source only by OID (`tableID` / `columnID`);
235
- * the frontend needs schema/table/column names to decide whether a grid is
236
- * editable and to build UPDATEs, so they are resolved here and cached.
237
- */
238
- relations = /* @__PURE__ */ new Map();
239
- async loadTypeNames() {
240
- try {
241
- const res = await this.client.query(
242
- "SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
243
- );
244
- for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
245
- } catch {
246
- }
75
+ }
76
+ async function loadLines(file, seen) {
77
+ if (seen.has(file)) return [];
78
+ seen.add(file);
79
+ try {
80
+ return (await readFile(file, "utf8")).split(/\r?\n/);
81
+ } catch {
82
+ return [];
247
83
  }
248
- async resolveRelations(tableIds) {
249
- const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
250
- if (missing.length === 0) return;
251
- try {
252
- const res = await this.client.query(
253
- `SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
254
- FROM pg_class c
255
- JOIN pg_namespace n ON n.oid = c.relnamespace
256
- JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
257
- WHERE c.oid = ANY($1::oid[])`,
258
- [missing]
259
- );
260
- for (const row of res.rows) {
261
- const oid = Number(row.oid);
262
- let rel = this.relations.get(oid);
263
- if (!rel) {
264
- rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
265
- this.relations.set(oid, rel);
266
- }
267
- rel.columns.set(Number(row.attnum), row.attname);
84
+ }
85
+ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
86
+ const result = { identityFiles: [] };
87
+ if (!file) return result;
88
+ const seen = /* @__PURE__ */ new Set();
89
+ async function walk(path, active) {
90
+ let blockActive = active;
91
+ for (const line of await loadLines(path, seen)) {
92
+ const d = parseLine(line);
93
+ if (!d) continue;
94
+ switch (d.keyword) {
95
+ case "host":
96
+ blockActive = hostPatternMatches(d.args, host);
97
+ continue;
98
+ case "match":
99
+ blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
100
+ continue;
101
+ case "include":
102
+ for (const inc of d.args) {
103
+ const target = expandHome(inc);
104
+ const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
105
+ for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
106
+ }
107
+ continue;
108
+ }
109
+ if (!blockActive) continue;
110
+ const value = d.args[0];
111
+ if (value === void 0) continue;
112
+ switch (d.keyword) {
113
+ case "hostname":
114
+ result.hostName ??= value.replace(/%h/g, host);
115
+ break;
116
+ case "port":
117
+ result.port ??= Number(value) || void 0;
118
+ break;
119
+ case "user":
120
+ result.user ??= value;
121
+ break;
122
+ case "identityfile":
123
+ for (const f of d.args) {
124
+ const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
125
+ if (expanded.split(/[\\/]/).includes("..")) continue;
126
+ result.identityFiles.push(expanded);
127
+ }
128
+ break;
129
+ case "identityagent":
130
+ result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
131
+ break;
132
+ case "proxyjump":
133
+ result.proxyJump ??= value;
134
+ break;
268
135
  }
269
- } catch {
270
- }
271
- }
272
- async query(sql2, database) {
273
- if (database && database !== this.currentSchema) {
274
- await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
275
- this.currentSchema = database;
276
- }
277
- const result = await this.client.query({ text: sql2, rowMode: "array" });
278
- if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
279
- const results = Array.isArray(result) ? result : [result];
280
- const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
281
- if (last.fields.length > 0) {
282
- await this.resolveRelations(last.fields.map((f) => f.tableID));
283
- const fields = last.fields.map((field) => {
284
- const rel = this.relations.get(field.tableID);
285
- return {
286
- name: field.name,
287
- orgName: rel?.columns.get(field.columnID) ?? field.name,
288
- type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
289
- table: rel?.table ?? "",
290
- orgTable: rel?.table ?? "",
291
- db: rel?.schema ?? "",
292
- maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
293
- };
294
- });
295
- return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
296
136
  }
297
- return {
298
- fields: [],
299
- records: [],
300
- affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
301
- insertId: 0,
302
- isResultQuery: false
303
- };
304
137
  }
305
- async close() {
306
- await this.client.end();
138
+ await walk(file, true);
139
+ return result;
140
+ }
141
+
142
+ // src/ssh-tunnel.ts
143
+ var TunnelError = class extends Error {
144
+ constructor(message, status) {
145
+ super(message);
146
+ this.status = status;
147
+ this.name = "TunnelError";
307
148
  }
149
+ status;
308
150
  };
309
- var postgresDriverFactory = {
310
- async open(config) {
311
- const client = new pg.Client(toClientConfig(config));
312
- await client.connect();
313
- const driver = new PostgresDriver(client, config);
314
- await driver.loadTypeNames();
315
- return driver;
316
- },
317
- async test(config) {
318
- const client = new pg.Client(toClientConfig(config));
319
- await client.connect();
320
- await client.end();
321
- },
322
- parseConfig(body) {
323
- const host = typeof body.host === "string" ? body.host.trim() : "";
324
- const user = typeof body.user === "string" ? body.user : "";
325
- if (!host || !user) {
326
- throw new Error("host and user are required");
327
- }
328
- return {
329
- driver: "postgres",
330
- host,
331
- port: Number(body.port) || 5432,
332
- user,
333
- password: typeof body.password === "string" ? body.password : "",
334
- database: typeof body.database === "string" && body.database ? body.database : "postgres",
335
- ssl: body.ssl === true,
336
- sslVerify: body.sslVerify === true
337
- };
151
+ var HostKeyError = class extends TunnelError {
152
+ hostKey;
153
+ constructor(host, port, type, fingerprint2) {
154
+ super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
155
+ this.name = "HostKeyError";
156
+ this.hostKey = { host, port, type, fingerprint: fingerprint2 };
338
157
  }
339
158
  };
340
-
341
- // src/drivers/sqlite.ts
342
- import Database from "better-sqlite3";
343
- import { existsSync } from "fs";
344
- import { isAbsolute, resolve, sep } from "path";
345
- function normalizeDeclaredType(declared) {
346
- if (!declared) return null;
347
- const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
348
- return base || null;
159
+ var CONNECT_TIMEOUT_MS = 2e4;
160
+ var PLANCHE_DIR = join2(homedir2(), ".planche");
161
+ var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
162
+ var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
163
+ function parseSshConfig(value) {
164
+ if (value === void 0 || value === null) return void 0;
165
+ if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
166
+ const body = value;
167
+ const host = typeof body.host === "string" ? body.host.trim() : "";
168
+ const user = typeof body.user === "string" ? body.user.trim() : "";
169
+ const auth = body.auth;
170
+ if (!host) throw new TunnelError("ssh.host is required", 400);
171
+ if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
172
+ if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
173
+ const port = Math.trunc(Number(body.port)) || 22;
174
+ if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
175
+ if (auth !== "password" && auth !== "key" && auth !== "agent") {
176
+ throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
177
+ }
178
+ const str = (v) => typeof v === "string" && v ? v : void 0;
179
+ const config = {
180
+ host,
181
+ port,
182
+ user,
183
+ auth,
184
+ password: auth === "password" ? str(body.password) ?? "" : void 0,
185
+ privateKey: auth === "key" ? str(body.privateKey) : void 0,
186
+ passphrase: auth === "key" ? str(body.passphrase) : void 0,
187
+ acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
188
+ };
189
+ return config;
349
190
  }
350
- function inferTypeFromValue(value) {
351
- if (value === null || value === void 0) return "null";
352
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
353
- if (typeof value === "bigint") return "bigint";
354
- if (Buffer.isBuffer(value)) return "blob";
355
- return "text";
191
+ function keyType(blob) {
192
+ if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
193
+ const len = blob.readUInt32BE(0);
194
+ if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
195
+ const type = blob.subarray(4, 4 + len).toString("ascii");
196
+ if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
197
+ return type;
356
198
  }
357
- var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(process.env.PLANCHE_SQLITE_DIR) : null;
358
- function resolveFile(file) {
359
- if (file === ":memory:") return file;
360
- if (file.startsWith("file:")) {
361
- if (SQLITE_DIR) throw new Error("URI filenames are disabled while PLANCHE_SQLITE_DIR is set");
362
- return file;
199
+ function knownHostsEntryMatches(field, patterns) {
200
+ if (field.startsWith("|1|")) {
201
+ const [, , salt, hash] = field.split("|");
202
+ if (!salt || !hash) return false;
203
+ const key = Buffer.from(salt, "base64");
204
+ const want = Buffer.from(hash, "base64");
205
+ return patterns.some((p) => {
206
+ const got = createHmac("sha1", key).update(p).digest();
207
+ return got.length === want.length && timingSafeEqual(got, want);
208
+ });
363
209
  }
364
- const resolved = isAbsolute(file) ? file : resolve(SQLITE_DIR ?? process.cwd(), file);
365
- if (SQLITE_DIR && resolved !== SQLITE_DIR && !resolved.startsWith(SQLITE_DIR + sep)) {
366
- throw new Error(`SQLite file must be inside ${SQLITE_DIR}`);
210
+ const entries = field.toLowerCase().split(",");
211
+ return patterns.some((p) => hostPatternMatches(entries, p));
212
+ }
213
+ function hostPatterns(host, port) {
214
+ const h = host.toLowerCase();
215
+ return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
216
+ }
217
+ async function readKnownHosts(file) {
218
+ try {
219
+ return (await readFile2(file, "utf8")).split(/\r?\n/);
220
+ } catch {
221
+ return [];
367
222
  }
368
- return resolved;
369
223
  }
370
- function openDatabase(config) {
371
- const file = resolveFile(config.file);
372
- const isMemory = file === ":memory:";
373
- if (!isMemory && !config.createIfMissing && !existsSync(file)) {
374
- throw new Error(`SQLite file not found: ${file}`);
224
+ async function lookupHostKey(host, port, blob) {
225
+ const patterns = hostPatterns(host, port);
226
+ const type = keyType(blob);
227
+ const encoded = blob.toString("base64");
228
+ let sawHost = false;
229
+ const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
230
+ for (const raw of lines) {
231
+ const line = raw.trim();
232
+ if (!line || line.startsWith("#")) continue;
233
+ const fields = line.split(/\s+/);
234
+ const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
235
+ const [hosts, lineType, lineKey] = fields;
236
+ if (!hosts || !lineType || !lineKey) continue;
237
+ if (!knownHostsEntryMatches(hosts, patterns)) continue;
238
+ if (marker === "@revoked") {
239
+ if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
240
+ continue;
241
+ }
242
+ if (marker === "@cert-authority") continue;
243
+ sawHost = true;
244
+ if (lineType === type && lineKey === encoded) return "match";
375
245
  }
376
- return new Database(file, {
377
- readonly: config.readonly ?? false,
378
- fileMustExist: !isMemory && !config.createIfMissing
379
- });
246
+ return sawHost ? "mismatch" : "unknown";
380
247
  }
381
- var SqliteDriver = class {
382
- constructor(db, config) {
383
- this.db = db;
384
- this.label = resolveFile(config.file);
248
+ async function rememberHostKey(host, port, blob) {
249
+ await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
250
+ const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
251
+ await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
252
+ `, { mode: 384 });
253
+ }
254
+ function fingerprint(blob) {
255
+ return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
256
+ }
257
+ async function resolveConfig(ssh) {
258
+ const cfg = await resolveSshHost(ssh.host);
259
+ if (cfg.proxyJump) {
260
+ throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
385
261
  }
386
- db;
387
- type = "sqlite";
388
- label;
389
- async query(sql2) {
390
- let statement;
391
- try {
392
- statement = this.db.prepare(sql2);
393
- } catch (err) {
394
- if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
395
- this.db.exec(sql2);
396
- return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
397
- }
398
- throw err;
399
- }
400
- if (!statement.reader) {
401
- const info = statement.run();
402
- return {
403
- fields: [],
404
- records: [],
405
- affectedRows: info.changes,
406
- insertId: Number(info.lastInsertRowid),
407
- isResultQuery: false
408
- };
409
- }
410
- const columns = statement.columns();
411
- const records = statement.raw(true).all();
412
- const fields = columns.map((col, index) => {
413
- const declared = normalizeDeclaredType(col.type);
414
- const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
415
- return {
416
- name: col.name,
417
- orgName: col.column ?? col.name,
418
- type,
419
- table: col.table ?? "",
420
- orgTable: col.table ?? "",
421
- db: col.database ?? "",
422
- maxLength: 0
423
- };
424
- });
425
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
262
+ if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
263
+ throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
426
264
  }
427
- async close() {
428
- if (this.db.inTransaction) {
265
+ const resolved = {
266
+ ...ssh,
267
+ host: cfg.hostName ?? ssh.host,
268
+ port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
269
+ user: ssh.user || cfg.user || ""
270
+ };
271
+ if (!resolved.user) {
272
+ throw new TunnelError(
273
+ SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
274
+ 400
275
+ );
276
+ }
277
+ if (resolved.auth === "key" && !resolved.privateKey) {
278
+ if (SSH_CONFIG_DISABLED) {
279
+ throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
280
+ }
281
+ const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
282
+ for (const file of candidates) {
429
283
  try {
430
- this.db.exec("ROLLBACK");
284
+ resolved.privateKey = await readFile2(file, "utf8");
285
+ console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
286
+ break;
431
287
  } catch {
432
288
  }
433
289
  }
434
- this.db.close();
290
+ if (!resolved.privateKey) {
291
+ throw new TunnelError(
292
+ `No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
293
+ 400
294
+ );
295
+ }
435
296
  }
436
- };
437
- function firstNonNull(records, index) {
438
- for (const row of records) {
439
- const value = row[index];
440
- if (value !== null && value !== void 0) return value;
297
+ return { ...resolved, identityAgent: cfg.identityAgent };
298
+ }
299
+ function toConnectConfig(ssh) {
300
+ const base = {
301
+ host: ssh.host,
302
+ port: ssh.port,
303
+ username: ssh.user,
304
+ readyTimeout: CONNECT_TIMEOUT_MS,
305
+ keepaliveInterval: 3e4
306
+ };
307
+ switch (ssh.auth) {
308
+ case "password":
309
+ return { ...base, password: ssh.password ?? "", tryKeyboard: true };
310
+ case "key":
311
+ return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
312
+ case "agent": {
313
+ const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
314
+ if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
315
+ return { ...base, agent };
316
+ }
441
317
  }
442
- return null;
443
318
  }
444
- var sqliteDriverFactory = {
445
- async open(config) {
446
- return new SqliteDriver(openDatabase(config), config);
447
- },
448
- async test(config) {
449
- const db = openDatabase(config);
319
+ function describeSshError(err, ssh) {
320
+ const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
321
+ if (err instanceof TunnelError) return err;
322
+ switch (err.level) {
323
+ case "client-authentication":
324
+ return new TunnelError(`SSH authentication failed for ${target}`, 401);
325
+ case "client-timeout":
326
+ return new TunnelError(`SSH connection to ${target} timed out`, 400);
327
+ default:
328
+ return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
329
+ }
330
+ }
331
+ async function openSshTunnel(requested, target) {
332
+ const ssh = await resolveConfig(requested);
333
+ const client = new Client();
334
+ const connectConfig = toConnectConfig(ssh);
335
+ let verifyError;
336
+ connectConfig.hostVerifier = (key, verified) => {
337
+ lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
338
+ const fp = fingerprint(key);
339
+ if (result === "mismatch") {
340
+ verifyError = new TunnelError(
341
+ `Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
342
+ 400
343
+ );
344
+ verified(false);
345
+ return;
346
+ }
347
+ if (result === "unknown") {
348
+ if (ssh.acceptHostKey !== fp) {
349
+ verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
350
+ verified(false);
351
+ return;
352
+ }
353
+ console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
354
+ await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
355
+ }
356
+ verified(true);
357
+ }).catch((err) => {
358
+ verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
359
+ verified(false);
360
+ });
361
+ };
362
+ await new Promise((resolve2, reject) => {
363
+ client.once("ready", resolve2);
364
+ client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
450
365
  try {
451
- db.prepare("SELECT 1").get();
452
- } finally {
453
- db.close();
454
- }
455
- },
456
- parseConfig(body) {
457
- const file = typeof body.file === "string" ? body.file.trim() : "";
458
- if (!file) {
459
- throw new Error("file is required");
366
+ client.connect(connectConfig);
367
+ } catch (err) {
368
+ reject(describeSshError(err, ssh));
460
369
  }
461
- return {
462
- driver: "sqlite",
463
- file,
464
- createIfMissing: body.createIfMissing === true,
465
- readonly: body.readonly === true
466
- };
467
- }
468
- };
370
+ });
371
+ client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
372
+ const sockets = /* @__PURE__ */ new Set();
373
+ let forwardError;
374
+ const server = createServer((socket) => {
375
+ sockets.add(socket);
376
+ socket.once("close", () => sockets.delete(socket));
377
+ socket.on("error", () => socket.destroy());
378
+ client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
379
+ if (err) {
380
+ const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
381
+ const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
382
+ forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
383
+ console.error(`[ssh] ${forwardError}`);
384
+ socket.destroy();
385
+ return;
386
+ }
387
+ stream.on("error", () => socket.destroy());
388
+ socket.pipe(stream).pipe(socket);
389
+ stream.once("close", () => socket.destroy());
390
+ socket.once("close", () => stream.destroy());
391
+ });
392
+ });
393
+ await new Promise((resolve2, reject) => {
394
+ server.once("error", reject);
395
+ server.listen(0, "127.0.0.1", () => resolve2());
396
+ }).catch((err) => {
397
+ client.end();
398
+ throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
399
+ });
400
+ const { port } = server.address();
401
+ let closed = false;
402
+ const close = () => new Promise((resolve2) => {
403
+ if (closed) return resolve2();
404
+ closed = true;
405
+ for (const s of sockets) s.destroy();
406
+ server.close(() => {
407
+ client.end();
408
+ resolve2();
409
+ });
410
+ });
411
+ client.once("close", () => void close());
412
+ return {
413
+ host: "127.0.0.1",
414
+ port,
415
+ get forwardError() {
416
+ return forwardError;
417
+ },
418
+ close
419
+ };
420
+ }
469
421
 
470
- // src/drivers/mssql.ts
471
- import sql from "mssql";
472
- function toPoolConfig(config) {
422
+ // src/tunneling.ts
423
+ import { randomUUID } from "crypto";
424
+
425
+ // src/drivers/mysql.ts
426
+ import mysql from "mysql2/promise";
427
+ var MYSQL_TYPES = {
428
+ 0: "decimal",
429
+ 1: "tinyint",
430
+ 2: "smallint",
431
+ 3: "int",
432
+ 4: "float",
433
+ 5: "double",
434
+ 6: "null",
435
+ 7: "timestamp",
436
+ 8: "bigint",
437
+ 9: "mediumint",
438
+ 10: "date",
439
+ 11: "time",
440
+ 12: "datetime",
441
+ 13: "year",
442
+ 14: "newdate",
443
+ 15: "varchar",
444
+ 16: "bit",
445
+ 245: "json",
446
+ 246: "newdecimal",
447
+ 247: "enum",
448
+ 248: "set",
449
+ 249: "tinyblob",
450
+ 250: "mediumblob",
451
+ 251: "longblob",
452
+ 252: "blob",
453
+ 253: "varstring",
454
+ 254: "string",
455
+ 255: "geometry"
456
+ };
457
+ function getTypeName(typeId) {
458
+ return MYSQL_TYPES[typeId] ?? "unknown";
459
+ }
460
+ function toConnectionOptions(config) {
473
461
  return {
474
- server: config.host,
462
+ host: config.host,
475
463
  port: config.port,
476
464
  user: config.user,
477
465
  password: config.password,
478
- database: config.database,
479
- // One physical connection: manual transactions and USE need session affinity.
480
- pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
481
- options: {
482
- encrypt: config.ssl === true,
483
- trustServerCertificate: true,
484
- // Keep date/time values as sent by the server rather than shifting to UTC.
485
- useUTC: false,
486
- enableArithAbort: true
487
- },
488
- requestTimeout: 0
466
+ database: config.database || void 0,
467
+ dateStrings: true,
468
+ charset: config.charset || "utf8mb4",
469
+ ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0
489
470
  };
490
471
  }
491
- var pad = (n, width = 2) => String(n).padStart(width, "0");
492
- function formatDate(d) {
493
- const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
494
- const ms = d.getMilliseconds();
495
- return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
496
- }
497
- function normalizeValue(value) {
498
- if (value instanceof Date) return formatDate(value);
499
- if (Buffer.isBuffer(value)) return value.toString("hex");
500
- return value;
501
- }
502
- var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
503
- var MssqlDriver = class {
504
- constructor(pool, config) {
505
- this.pool = pool;
506
- this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
472
+ var USE_STATEMENT = /^\s*USE\s/i;
473
+ var MySqlDriver = class {
474
+ constructor(connection, config) {
475
+ this.connection = connection;
476
+ this.label = `${config.user}@${config.host}:${config.port}`;
477
+ this.currentDatabase = config.database ?? null;
507
478
  }
508
- pool;
509
- type = "mssql";
479
+ connection;
480
+ type = "mysql";
510
481
  label;
511
- /** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
512
- sources = /* @__PURE__ */ new Map();
513
482
  /**
514
- * The `mssql` driver does not report which table a result column comes
515
- * from; ask the server to describe the statement instead so the grid can
516
- * decide whether it is editable and address rows by primary key.
483
+ * Default schema the session is currently in, or null when unknown (after
484
+ * the client ran its own `USE`). Tracked so the schema is switched with a
485
+ * plain `USE` only when it actually changes: `changeUser()` would reset the
486
+ * whole session on every call, silently rolling back open transactions and
487
+ * dropping temporary tables and user variables.
517
488
  */
518
- async describe(text) {
519
- const cached = this.sources.get(text);
520
- if (cached !== void 0) return cached;
521
- let result = null;
522
- try {
523
- const req = this.pool.request();
524
- req.input("sql", sql.NVarChar(sql.MAX), text);
525
- const res = await req.query(
526
- "SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
527
- );
528
- result = res.recordset.map((r) => ({
529
- schema: r.source_schema ?? "",
530
- table: r.source_table ?? "",
531
- column: r.source_column ?? "",
532
- type: (r.system_type_name ?? "").toLowerCase()
533
- }));
534
- } catch {
535
- result = null;
489
+ currentDatabase;
490
+ async query(sql2, database) {
491
+ if (database && database !== this.currentDatabase) {
492
+ await this.connection.query(`USE ${mysql.escapeId(database)}`);
493
+ this.currentDatabase = database;
536
494
  }
537
- if (this.sources.size > 200) this.sources.clear();
538
- this.sources.set(text, result);
539
- return result;
540
- }
541
- async query(text) {
542
- const req = this.pool.request();
543
- req.arrayRowMode = true;
544
- const res = await req.query(text);
545
- const sets = res.recordsets;
546
- const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
547
- const columns = last?.columns;
548
- if (last && columns) {
549
- const meta = Object.values(columns).sort((a, b) => a.index - b.index);
550
- const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
551
- const fields = meta.map((col, i) => {
552
- const src = sources && sources.length === meta.length ? sources[i] : void 0;
553
- const typeName = col.type?.declaration ?? "";
554
- return {
555
- name: col.name,
556
- orgName: src?.column || col.name,
557
- type: src?.type || typeName.toLowerCase(),
558
- table: src?.table ?? "",
559
- orgTable: src?.table ?? "",
560
- db: src?.schema ?? "",
561
- maxLength: typeof col.length === "number" ? col.length : 0
562
- };
563
- });
564
- const records = last.map((row) => row.map(normalizeValue));
565
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
495
+ const [results, fields] = await this.connection.query(sql2);
496
+ if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
497
+ if (Array.isArray(results) && fields) {
498
+ const fieldPackets = fields;
499
+ const mappedFields = fieldPackets.map((field) => ({
500
+ name: field.name,
501
+ orgName: field.orgName ?? field.name,
502
+ type: getTypeName(field.type ?? 253),
503
+ table: field.table ?? "",
504
+ orgTable: field.orgTable ?? "",
505
+ db: field.db ?? "",
506
+ maxLength: field.length ?? 0
507
+ }));
508
+ const records = results.map(
509
+ (row) => fieldPackets.map((field) => row[field.name])
510
+ );
511
+ return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
566
512
  }
513
+ const header = results;
567
514
  return {
568
515
  fields: [],
569
516
  records: [],
570
- affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
571
- insertId: 0,
517
+ affectedRows: header.affectedRows ?? 0,
518
+ insertId: header.insertId ?? 0,
572
519
  isResultQuery: false
573
520
  };
574
521
  }
575
522
  async close() {
576
- await this.pool.close();
523
+ await this.connection.end();
577
524
  }
578
525
  };
579
- var mssqlDriverFactory = {
526
+ var mysqlDriverFactory = {
580
527
  async open(config) {
581
- const pool = new sql.ConnectionPool(toPoolConfig(config));
582
- await pool.connect();
583
- return new MssqlDriver(pool, config);
528
+ const connection = await mysql.createConnection(toConnectionOptions(config));
529
+ return new MySqlDriver(connection, config);
584
530
  },
585
531
  async test(config) {
586
- const pool = new sql.ConnectionPool(toPoolConfig(config));
587
- await pool.connect();
588
- await pool.close();
532
+ const connection = await mysql.createConnection({
533
+ ...toConnectionOptions(config),
534
+ database: void 0
535
+ });
536
+ await connection.end();
589
537
  },
590
538
  parseConfig(body) {
591
539
  const host = typeof body.host === "string" ? body.host.trim() : "";
@@ -594,146 +542,191 @@ var mssqlDriverFactory = {
594
542
  throw new Error("host and user are required");
595
543
  }
596
544
  return {
597
- driver: "mssql",
545
+ driver: "mysql",
598
546
  host,
599
- port: Number(body.port) || 1433,
547
+ port: Number(body.port) || 3306,
600
548
  user,
601
549
  password: typeof body.password === "string" ? body.password : "",
602
- database: typeof body.database === "string" && body.database ? body.database : "master",
603
- ssl: body.ssl === true
550
+ database: typeof body.database === "string" && body.database ? body.database : void 0,
551
+ charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
552
+ ssl: body.ssl === true,
553
+ sslVerify: body.sslVerify === true
604
554
  };
605
555
  }
606
556
  };
607
557
 
608
- // src/drivers/oracle.ts
609
- import oracledb from "oracledb";
610
- oracledb.autoCommit = true;
611
- oracledb.fetchAsString = [oracledb.CLOB];
612
- var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
613
- var pad2 = (n, width = 2) => String(n).padStart(width, "0");
614
- function formatDate2(d) {
615
- const ms = d.getMilliseconds();
616
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
617
- }
618
- function normalizeValue2(value) {
619
- if (value instanceof Date) return formatDate2(value);
620
- if (Buffer.isBuffer(value)) return value.toString("hex");
621
- return value;
622
- }
623
- var SESSION_FORMATS = [
624
- "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
625
- "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
626
- "ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
558
+ // src/drivers/postgres.ts
559
+ import pg from "pg";
560
+ var TEXT_OIDS = [
561
+ 20,
562
+ // int8
563
+ 1700,
564
+ // numeric
565
+ 1082,
566
+ // date
567
+ 1114,
568
+ // timestamp
569
+ 1184,
570
+ // timestamptz
571
+ 1083,
572
+ // time
573
+ 1266,
574
+ // timetz
575
+ 1186
576
+ // interval
627
577
  ];
628
- var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
629
- var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
630
- function normalizeStatement(sql2) {
631
- const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
632
- if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
633
- return trimmed.replace(/;\s*$/, "");
634
- }
635
- var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
636
- var START_TX = /^\s*SET\s+TRANSACTION\b/i;
637
- var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
638
- function singleSourceTable(sql2) {
639
- const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
640
- if (!m) return null;
641
- const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
642
- if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
643
- const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
644
- return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
578
+ var types = new pg.TypeOverrides();
579
+ for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
580
+ var BUILTIN_TYPES = {
581
+ 16: "bool",
582
+ 17: "bytea",
583
+ 18: "char",
584
+ 19: "name",
585
+ 20: "int8",
586
+ 21: "int2",
587
+ 23: "int4",
588
+ 25: "text",
589
+ 26: "oid",
590
+ 114: "json",
591
+ 142: "xml",
592
+ 700: "float4",
593
+ 701: "float8",
594
+ 1042: "bpchar",
595
+ 1043: "varchar",
596
+ 1082: "date",
597
+ 1083: "time",
598
+ 1114: "timestamp",
599
+ 1184: "timestamptz",
600
+ 1186: "interval",
601
+ 1266: "timetz",
602
+ 1700: "numeric",
603
+ 2950: "uuid",
604
+ 3802: "jsonb",
605
+ 1560: "bit",
606
+ 1562: "varbit",
607
+ 600: "point",
608
+ 869: "inet",
609
+ 650: "cidr",
610
+ 829: "macaddr",
611
+ 1007: "_int4",
612
+ 1009: "_text"
613
+ };
614
+ function toClientConfig(config) {
615
+ return {
616
+ host: config.host,
617
+ port: config.port,
618
+ user: config.user,
619
+ password: config.password,
620
+ database: config.database,
621
+ ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
622
+ types
623
+ };
645
624
  }
646
- var OracleDriver = class {
647
- constructor(conn, config) {
648
- this.conn = conn;
649
- this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
650
- this.currentSchema = config.user.toUpperCase();
625
+ var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
626
+ var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
627
+ var PostgresDriver = class {
628
+ constructor(client, config) {
629
+ this.client = client;
630
+ this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
651
631
  }
652
- conn;
653
- type = "oracle";
632
+ client;
633
+ type = "postgres";
654
634
  label;
655
- currentSchema;
656
- manualTx = false;
657
- /** Column names per `OWNER.TABLE`, for result-source resolution. */
658
- columns = /* @__PURE__ */ new Map();
659
- async init() {
660
- for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
635
+ /**
636
+ * Schema currently first in `search_path`, or null when unknown (after the
637
+ * client set it itself). The app's "database" for a Postgres connection is
638
+ * a schema of the bound database, switched with `SET search_path`.
639
+ */
640
+ currentSchema = null;
641
+ typeNames = { ...BUILTIN_TYPES };
642
+ /**
643
+ * Result fields name their source only by OID (`tableID` / `columnID`);
644
+ * the frontend needs schema/table/column names to decide whether a grid is
645
+ * editable and to build UPDATEs, so they are resolved here and cached.
646
+ */
647
+ relations = /* @__PURE__ */ new Map();
648
+ async loadTypeNames() {
649
+ try {
650
+ const res = await this.client.query(
651
+ "SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
652
+ );
653
+ for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
654
+ } catch {
655
+ }
661
656
  }
662
- async tableColumns(schema, table) {
663
- const key = `${schema}.${table}`;
664
- const cached = this.columns.get(key);
665
- if (cached !== void 0) return cached;
666
- let out = null;
657
+ async resolveRelations(tableIds) {
658
+ const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
659
+ if (missing.length === 0) return;
667
660
  try {
668
- const res = await this.conn.execute(
669
- "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
670
- { o: schema, t: table },
671
- { outFormat: oracledb.OUT_FORMAT_ARRAY }
661
+ const res = await this.client.query(
662
+ `SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
663
+ FROM pg_class c
664
+ JOIN pg_namespace n ON n.oid = c.relnamespace
665
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
666
+ WHERE c.oid = ANY($1::oid[])`,
667
+ [missing]
672
668
  );
673
- out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
669
+ for (const row of res.rows) {
670
+ const oid = Number(row.oid);
671
+ let rel = this.relations.get(oid);
672
+ if (!rel) {
673
+ rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
674
+ this.relations.set(oid, rel);
675
+ }
676
+ rel.columns.set(Number(row.attnum), row.attname);
677
+ }
674
678
  } catch {
675
- out = null;
676
679
  }
677
- if (this.columns.size > 200) this.columns.clear();
678
- this.columns.set(key, out);
679
- return out;
680
680
  }
681
681
  async query(sql2, database) {
682
682
  if (database && database !== this.currentSchema) {
683
- await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
683
+ await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
684
684
  this.currentSchema = database;
685
685
  }
686
- const text = normalizeStatement(sql2);
687
- if (START_TX.test(text)) this.manualTx = true;
688
- const res = await this.conn.execute(text, [], {
689
- outFormat: oracledb.OUT_FORMAT_ARRAY,
690
- fetchTypeHandler,
691
- autoCommit: !this.manualTx
692
- });
693
- if (SET_SCHEMA.test(text)) this.currentSchema = null;
694
- if (END_TX.test(text)) this.manualTx = false;
695
- if (res.metaData && res.metaData.length > 0) {
696
- const source = singleSourceTable(text);
697
- const schema = source?.schema ?? this.currentSchema ?? "";
698
- const known = source && schema ? await this.tableColumns(schema, source.table) : null;
699
- const fields = res.metaData.map((col) => {
700
- const fromTable = known?.has(col.name) ?? false;
686
+ const result = await this.client.query({ text: sql2, rowMode: "array" });
687
+ if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
688
+ const results = Array.isArray(result) ? result : [result];
689
+ const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
690
+ if (last.fields.length > 0) {
691
+ await this.resolveRelations(last.fields.map((f) => f.tableID));
692
+ const fields = last.fields.map((field) => {
693
+ const rel = this.relations.get(field.tableID);
701
694
  return {
702
- name: col.name,
703
- orgName: col.name,
704
- type: (col.dbTypeName ?? "").toLowerCase(),
705
- table: fromTable ? source.table : "",
706
- orgTable: fromTable ? source.table : "",
707
- db: fromTable ? schema : "",
708
- maxLength: col.byteSize ?? 0
695
+ name: field.name,
696
+ orgName: rel?.columns.get(field.columnID) ?? field.name,
697
+ type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
698
+ table: rel?.table ?? "",
699
+ orgTable: rel?.table ?? "",
700
+ db: rel?.schema ?? "",
701
+ maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
709
702
  };
710
703
  });
711
- const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
712
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
704
+ return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
713
705
  }
714
- return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
706
+ return {
707
+ fields: [],
708
+ records: [],
709
+ affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
710
+ insertId: 0,
711
+ isResultQuery: false
712
+ };
715
713
  }
716
714
  async close() {
717
- await this.conn.close();
715
+ await this.client.end();
718
716
  }
719
717
  };
720
- function connectionAttributes(config) {
721
- return {
722
- user: config.user,
723
- password: config.password,
724
- connectString: `${config.host}:${config.port}/${config.serviceName}`
725
- };
726
- }
727
- var oracleDriverFactory = {
728
- async open(config) {
729
- const conn = await oracledb.getConnection(connectionAttributes(config));
730
- const driver = new OracleDriver(conn, config);
731
- await driver.init();
718
+ var postgresDriverFactory = {
719
+ async open(config) {
720
+ const client = new pg.Client(toClientConfig(config));
721
+ await client.connect();
722
+ const driver = new PostgresDriver(client, config);
723
+ await driver.loadTypeNames();
732
724
  return driver;
733
725
  },
734
726
  async test(config) {
735
- const conn = await oracledb.getConnection(connectionAttributes(config));
736
- await conn.close();
727
+ const client = new pg.Client(toClientConfig(config));
728
+ await client.connect();
729
+ await client.end();
737
730
  },
738
731
  parseConfig(body) {
739
732
  const host = typeof body.host === "string" ? body.host.trim() : "";
@@ -742,525 +735,694 @@ var oracleDriverFactory = {
742
735
  throw new Error("host and user are required");
743
736
  }
744
737
  return {
745
- driver: "oracle",
738
+ driver: "postgres",
746
739
  host,
747
- port: Number(body.port) || 1521,
740
+ port: Number(body.port) || 5432,
748
741
  user,
749
742
  password: typeof body.password === "string" ? body.password : "",
750
- serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
743
+ database: typeof body.database === "string" && body.database ? body.database : "postgres",
744
+ ssl: body.ssl === true,
745
+ sslVerify: body.sslVerify === true
751
746
  };
752
747
  }
753
748
  };
754
749
 
755
- // src/drivers/d1.ts
756
- var API = "https://api.cloudflare.com/client/v4";
757
- var D1Error = class extends Error {
758
- constructor(message, status) {
759
- super(message);
760
- this.status = status;
761
- }
762
- status;
763
- };
764
- var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
765
- var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
766
- var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
767
- function singleSourceTable2(sql2) {
768
- const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
769
- if (!m) return null;
770
- const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
771
- if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
772
- const raw = m[1];
773
- const first = raw[0];
774
- if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
775
- if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
776
- if (first === "[") return raw.slice(1, -1);
777
- return raw;
778
- }
779
- function normalizeDeclaredType2(declared) {
780
- if (typeof declared !== "string") return null;
750
+ // src/drivers/sqlite.ts
751
+ import Database from "better-sqlite3";
752
+ import { existsSync, realpathSync } from "fs";
753
+ import { homedir as homedir3 } from "os";
754
+ import { basename as basename3, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
755
+ function normalizeDeclaredType(declared) {
756
+ if (!declared) return null;
781
757
  const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
782
758
  return base || null;
783
759
  }
784
- function inferTypeFromValue2(value) {
760
+ function inferTypeFromValue(value) {
785
761
  if (value === null || value === void 0) return "null";
786
762
  if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
787
- if (Array.isArray(value)) return "blob";
763
+ if (typeof value === "bigint") return "bigint";
764
+ if (Buffer.isBuffer(value)) return "blob";
788
765
  return "text";
789
766
  }
790
- function firstNonNull2(records, index) {
791
- for (const row of records) {
792
- const value = row[index];
793
- if (value !== null && value !== void 0) return value;
794
- }
795
- return null;
767
+ var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
768
+ var DEFAULT_ROOT = homedir3();
769
+ var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
770
+ var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
771
+ var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
772
+ function expandHome2(path) {
773
+ return path === "~" || path.startsWith("~/") ? resolve(homedir3(), path.slice(2)) : path;
796
774
  }
797
- var D1Driver = class {
798
- type = "d1";
799
- label;
800
- url;
801
- headers;
802
- /** Declared column types per table, for result-source resolution. */
803
- columns = /* @__PURE__ */ new Map();
804
- constructor(config) {
805
- this.label = `d1:${config.databaseId}`;
806
- this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
807
- this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
808
- }
809
- async raw(sql2) {
810
- let res;
811
- try {
812
- res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
813
- } catch (err) {
814
- throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
815
- }
816
- let body = null;
817
- try {
818
- body = await res.json();
819
- } catch {
820
- }
821
- if (!res.ok || !body?.success) {
822
- const message = body?.errors?.map((e) => e.message).join("; ") || `Cloudflare API responded ${res.status}`;
823
- throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
824
- }
825
- const result = body.result?.[0];
826
- if (!result) throw new D1Error("Cloudflare API returned no result");
827
- return result;
828
- }
829
- async tableColumns(table) {
830
- const cached = this.columns.get(table);
831
- if (cached !== void 0) return cached;
832
- let out = null;
775
+ function realish(file) {
776
+ let probe = file;
777
+ const rest = [];
778
+ for (; ; ) {
833
779
  try {
834
- const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
835
- const cols = info.results?.columns ?? [];
836
- const name = cols.indexOf("name");
837
- const type = cols.indexOf("type");
838
- const rows = info.results?.rows ?? [];
839
- if (rows.length > 0 && name >= 0) {
840
- out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
841
- }
780
+ return rest.length ? join3(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
842
781
  } catch {
843
- out = null;
844
- }
845
- if (this.columns.size > 200) this.columns.clear();
846
- this.columns.set(table, out);
847
- return out;
848
- }
849
- async query(sql2) {
850
- if (DATABASE_LIST.test(sql2)) {
851
- return {
852
- fields: [
853
- { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
854
- { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
855
- { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
856
- ],
857
- records: [[0, "main", ""]],
858
- affectedRows: 0,
859
- insertId: 0,
860
- isResultQuery: true
861
- };
862
- }
863
- const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
864
- if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
865
- throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
866
- }
867
- if (this.isDdl(text)) this.columns.clear();
868
- const result = await this.raw(text);
869
- const columns = result.results?.columns ?? [];
870
- const records = result.results?.rows ?? [];
871
- if (columns.length === 0 && !RESULT_KIND.test(text)) {
872
- return {
873
- fields: [],
874
- records: [],
875
- affectedRows: result.meta?.changes ?? 0,
876
- insertId: result.meta?.last_row_id ?? 0,
877
- isResultQuery: false
878
- };
879
- }
880
- const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
881
- const known = source ? await this.tableColumns(source) : null;
882
- const fields = columns.map((name, index) => {
883
- const fromTable = known?.has(name) ?? false;
884
- const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
885
- return {
886
- name,
887
- orgName: name,
888
- type,
889
- table: fromTable && source ? source : "",
890
- orgTable: fromTable && source ? source : "",
891
- db: fromTable ? "main" : "",
892
- maxLength: 0
893
- };
894
- });
895
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
896
- }
897
- isDdl(sql2) {
898
- return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
899
- }
900
- async close() {
901
- this.columns.clear();
902
- }
903
- };
904
- var d1DriverFactory = {
905
- async open(config) {
906
- const driver = new D1Driver(config);
907
- await driver.raw("SELECT 1");
908
- return driver;
909
- },
910
- async test(config) {
911
- await new D1Driver(config).raw("SELECT 1");
912
- },
913
- parseConfig(body) {
914
- const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
915
- const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
916
- const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
917
- if (!accountId || !databaseId || !apiToken) {
918
- throw new Error("accountId, databaseId and apiToken are required");
782
+ const parent = dirname2(probe);
783
+ if (parent === probe) return file;
784
+ rest.unshift(basename3(probe));
785
+ probe = parent;
919
786
  }
920
- return { driver: "d1", accountId, databaseId, apiToken };
921
787
  }
922
- };
923
-
924
- // src/ssh-tunnel.ts
925
- import { createServer } from "net";
926
- import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
927
- import { createHash } from "crypto";
928
- import { homedir as homedir2 } from "os";
929
- import { join as join2 } from "path";
930
- import { Client } from "ssh2";
931
-
932
- // src/ssh-config.ts
933
- import { readFile } from "fs/promises";
934
- import { homedir } from "os";
935
- import { dirname, isAbsolute as isAbsolute2, join } from "path";
936
- var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
937
- var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
938
- var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
939
- (f) => join(homedir(), ".ssh", f)
940
- );
941
- function expandHome(path) {
942
- return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
943
788
  }
944
- function globToRegExp(pattern) {
945
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
946
- return new RegExp(`^${escaped}$`, "i");
789
+ function isInside(path, root) {
790
+ return path === root || path.startsWith(root + sep);
947
791
  }
948
- function hostPatternMatches(patterns, host) {
949
- let matched = false;
950
- for (const raw of patterns) {
951
- const negated = raw.startsWith("!");
952
- const pattern = negated ? raw.slice(1) : raw;
953
- if (!globToRegExp(pattern).test(host)) continue;
954
- if (negated) return false;
955
- matched = true;
792
+ function resolveFile(file, forCreate = false) {
793
+ if (file === ":memory:") return file;
794
+ if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
795
+ const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
796
+ if (SQLITE_DIR) {
797
+ if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
798
+ } else {
799
+ const root = realish(DEFAULT_ROOT);
800
+ if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
801
+ const parts = relative(root, resolved).split(sep);
802
+ if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
803
+ if (DENIED_UNDER_HOME.some((d) => d.toLowerCase() === parts[0].toLowerCase())) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
804
+ }
805
+ if (forCreate && !DB_EXTENSIONS.test(resolved)) {
806
+ throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
956
807
  }
957
- return matched;
958
- }
959
- function tokenize(line) {
960
- const tokens = [];
961
- const re = /"([^"]*)"|(\S+)/g;
962
- let m;
963
- while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
964
- return tokens;
965
- }
966
- function parseLine(line) {
967
- const trimmed = line.trim();
968
- if (!trimmed || trimmed.startsWith("#")) return void 0;
969
- const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
970
- const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
971
- return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
808
+ return resolved;
972
809
  }
973
- async function loadLines(file, seen) {
974
- if (seen.has(file)) return [];
975
- seen.add(file);
976
- try {
977
- return (await readFile(file, "utf8")).split(/\r?\n/);
978
- } catch {
979
- return [];
810
+ function openDatabase(config) {
811
+ const file = resolveFile(config.file, config.createIfMissing === true);
812
+ const isMemory = file === ":memory:";
813
+ if (!isMemory && !config.createIfMissing && !existsSync(file)) {
814
+ throw new Error(`SQLite file not found: ${config.file}`);
980
815
  }
981
- }
982
- async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
983
- const result = { identityFiles: [] };
984
- if (!file) return result;
985
- const seen = /* @__PURE__ */ new Set();
986
- async function walk(path, active) {
987
- let blockActive = active;
988
- for (const line of await loadLines(path, seen)) {
989
- const d = parseLine(line);
990
- if (!d) continue;
991
- switch (d.keyword) {
992
- case "host":
993
- blockActive = hostPatternMatches(d.args, host);
994
- continue;
995
- case "match":
996
- blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
997
- continue;
998
- case "include":
999
- for (const inc of d.args) {
1000
- const target = expandHome(inc);
1001
- await walk(isAbsolute2(target) ? target : join(dirname(file), target), blockActive);
816
+ const db = new Database(file, {
817
+ readonly: config.readonly ?? false,
818
+ fileMustExist: !isMemory && !config.createIfMissing
819
+ });
820
+ db.pragma("trusted_schema = OFF");
821
+ if (config.readonly) db.pragma("query_only = ON");
822
+ return db;
823
+ }
824
+ function stripLiteralsAndComments(sql2) {
825
+ let out = "";
826
+ let i = 0;
827
+ while (i < sql2.length) {
828
+ const c = sql2[i];
829
+ if (c === "'" || c === '"' || c === "`") {
830
+ let j = i + 1;
831
+ while (j < sql2.length) {
832
+ if (sql2[j] === c) {
833
+ if (sql2[j + 1] === c) j += 2;
834
+ else {
835
+ j++;
836
+ break;
1002
837
  }
1003
- continue;
1004
- }
1005
- if (!blockActive) continue;
1006
- const value = d.args[0];
1007
- if (value === void 0) continue;
1008
- switch (d.keyword) {
1009
- case "hostname":
1010
- result.hostName ??= value.replace(/%h/g, host);
1011
- break;
1012
- case "port":
1013
- result.port ??= Number(value) || void 0;
1014
- break;
1015
- case "user":
1016
- result.user ??= value;
1017
- break;
1018
- case "identityfile":
1019
- for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
1020
- break;
1021
- case "identityagent":
1022
- result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
1023
- break;
1024
- case "proxyjump":
1025
- result.proxyJump ??= value;
1026
- break;
838
+ } else j++;
1027
839
  }
840
+ out += c + c;
841
+ i = j;
842
+ continue;
1028
843
  }
844
+ if (c === "[") {
845
+ const j = sql2.indexOf("]", i);
846
+ i = j < 0 ? sql2.length : j + 1;
847
+ out += "[]";
848
+ continue;
849
+ }
850
+ if (c === "-" && sql2[i + 1] === "-") {
851
+ const j = sql2.indexOf("\n", i);
852
+ i = j < 0 ? sql2.length : j;
853
+ out += " ";
854
+ continue;
855
+ }
856
+ if (c === "/" && sql2[i + 1] === "*") {
857
+ const j = sql2.indexOf("*/", i + 2);
858
+ i = j < 0 ? sql2.length : j + 2;
859
+ out += " ";
860
+ continue;
861
+ }
862
+ out += c;
863
+ i++;
1029
864
  }
1030
- await walk(file, true);
1031
- return result;
865
+ return out;
1032
866
  }
1033
-
1034
- // src/ssh-tunnel.ts
1035
- var TunnelError = class extends Error {
1036
- constructor(message, status) {
1037
- super(message);
1038
- this.status = status;
1039
- this.name = "TunnelError";
867
+ function assertNoFileStatements(sql2) {
868
+ const text = stripLiteralsAndComments(sql2);
869
+ if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
870
+ throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
871
+ }
872
+ if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
873
+ throw new Error("VACUUM INTO is not available through the agent");
874
+ }
875
+ if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
876
+ throw new Error("PRAGMA temp_store_directory is not available through the agent");
877
+ }
878
+ }
879
+ var SqliteDriver = class {
880
+ constructor(db, config) {
881
+ this.db = db;
882
+ this.label = config.file;
883
+ }
884
+ db;
885
+ type = "sqlite";
886
+ label;
887
+ async query(sql2) {
888
+ assertNoFileStatements(sql2);
889
+ let statement;
890
+ try {
891
+ statement = this.db.prepare(sql2);
892
+ } catch (err) {
893
+ if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
894
+ this.db.exec(sql2);
895
+ return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
896
+ }
897
+ throw err;
898
+ }
899
+ if (!statement.reader) {
900
+ const info = statement.run();
901
+ return {
902
+ fields: [],
903
+ records: [],
904
+ affectedRows: info.changes,
905
+ insertId: Number(info.lastInsertRowid),
906
+ isResultQuery: false
907
+ };
908
+ }
909
+ const columns = statement.columns();
910
+ const records = statement.raw(true).all();
911
+ const fields = columns.map((col, index) => {
912
+ const declared = normalizeDeclaredType(col.type);
913
+ const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
914
+ return {
915
+ name: col.name,
916
+ orgName: col.column ?? col.name,
917
+ type,
918
+ table: col.table ?? "",
919
+ orgTable: col.table ?? "",
920
+ db: col.database ?? "",
921
+ maxLength: 0
922
+ };
923
+ });
924
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
925
+ }
926
+ async close() {
927
+ if (this.db.inTransaction) {
928
+ try {
929
+ this.db.exec("ROLLBACK");
930
+ } catch {
931
+ }
932
+ }
933
+ this.db.close();
1040
934
  }
1041
- status;
1042
935
  };
1043
- var CONNECT_TIMEOUT_MS = 2e4;
1044
- var PLANCHE_DIR = join2(homedir2(), ".planche");
1045
- var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
1046
- var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
1047
- function parseSshConfig(value) {
1048
- if (value === void 0 || value === null) return void 0;
1049
- if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
1050
- const body = value;
1051
- const host = typeof body.host === "string" ? body.host.trim() : "";
1052
- const user = typeof body.user === "string" ? body.user.trim() : "";
1053
- const auth = body.auth;
1054
- if (!host) throw new TunnelError("ssh.host is required", 400);
1055
- if (auth !== "password" && auth !== "key" && auth !== "agent") {
1056
- throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
936
+ function firstNonNull(records, index) {
937
+ for (const row of records) {
938
+ const value = row[index];
939
+ if (value !== null && value !== void 0) return value;
1057
940
  }
1058
- const str = (v) => typeof v === "string" && v ? v : void 0;
1059
- const config = {
1060
- host,
1061
- port: Number(body.port) || 22,
1062
- user,
1063
- auth,
1064
- password: auth === "password" ? str(body.password) ?? "" : void 0,
1065
- privateKey: auth === "key" ? str(body.privateKey) : void 0,
1066
- passphrase: auth === "key" ? str(body.passphrase) : void 0
941
+ return null;
942
+ }
943
+ var sqliteDriverFactory = {
944
+ async open(config) {
945
+ return new SqliteDriver(openDatabase(config), config);
946
+ },
947
+ async test(config) {
948
+ const db = openDatabase(config);
949
+ try {
950
+ db.prepare("SELECT 1").get();
951
+ } finally {
952
+ db.close();
953
+ }
954
+ },
955
+ parseConfig(body) {
956
+ const file = typeof body.file === "string" ? body.file.trim() : "";
957
+ if (!file) {
958
+ throw new Error("file is required");
959
+ }
960
+ return {
961
+ driver: "sqlite",
962
+ file,
963
+ createIfMissing: body.createIfMissing === true,
964
+ readonly: body.readonly === true
965
+ };
966
+ }
967
+ };
968
+
969
+ // src/drivers/mssql.ts
970
+ import sql from "mssql";
971
+ function toPoolConfig(config) {
972
+ return {
973
+ server: config.host,
974
+ port: config.port,
975
+ user: config.user,
976
+ password: config.password,
977
+ database: config.database,
978
+ // One physical connection: manual transactions and USE need session affinity.
979
+ pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
980
+ options: {
981
+ encrypt: config.ssl === true,
982
+ trustServerCertificate: config.sslVerify !== true,
983
+ // Keep date/time values as sent by the server rather than shifting to UTC.
984
+ useUTC: false,
985
+ enableArithAbort: true
986
+ },
987
+ requestTimeout: 0
1067
988
  };
1068
- return config;
1069
989
  }
1070
- function keyType(blob) {
1071
- const len = blob.readUInt32BE(0);
1072
- return blob.subarray(4, 4 + len).toString("ascii");
990
+ var pad = (n, width = 2) => String(n).padStart(width, "0");
991
+ function formatDate(d) {
992
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
993
+ const ms = d.getMilliseconds();
994
+ return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
1073
995
  }
1074
- function hostPatterns(host, port) {
1075
- const h = host.toLowerCase();
1076
- return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
996
+ function normalizeValue(value) {
997
+ if (value instanceof Date) return formatDate(value);
998
+ if (Buffer.isBuffer(value)) return value.toString("hex");
999
+ return value;
1077
1000
  }
1078
- async function readKnownHosts(file) {
1079
- try {
1080
- return (await readFile2(file, "utf8")).split(/\r?\n/);
1081
- } catch {
1082
- return [];
1001
+ var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
1002
+ var MssqlDriver = class {
1003
+ constructor(pool, config) {
1004
+ this.pool = pool;
1005
+ this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
1006
+ }
1007
+ pool;
1008
+ type = "mssql";
1009
+ label;
1010
+ /** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
1011
+ sources = /* @__PURE__ */ new Map();
1012
+ /**
1013
+ * The `mssql` driver does not report which table a result column comes
1014
+ * from; ask the server to describe the statement instead so the grid can
1015
+ * decide whether it is editable and address rows by primary key.
1016
+ */
1017
+ async describe(text) {
1018
+ const cached = this.sources.get(text);
1019
+ if (cached !== void 0) return cached;
1020
+ let result = null;
1021
+ try {
1022
+ const req = this.pool.request();
1023
+ req.input("sql", sql.NVarChar(sql.MAX), text);
1024
+ const res = await req.query(
1025
+ "SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
1026
+ );
1027
+ result = res.recordset.map((r) => ({
1028
+ schema: r.source_schema ?? "",
1029
+ table: r.source_table ?? "",
1030
+ column: r.source_column ?? "",
1031
+ type: (r.system_type_name ?? "").toLowerCase()
1032
+ }));
1033
+ } catch {
1034
+ result = null;
1035
+ }
1036
+ if (this.sources.size > 200) this.sources.clear();
1037
+ this.sources.set(text, result);
1038
+ return result;
1039
+ }
1040
+ async query(text) {
1041
+ const req = this.pool.request();
1042
+ req.arrayRowMode = true;
1043
+ const res = await req.query(text);
1044
+ const sets = res.recordsets;
1045
+ const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
1046
+ const columns = last?.columns;
1047
+ if (last && columns) {
1048
+ const meta = Object.values(columns).sort((a, b) => a.index - b.index);
1049
+ const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
1050
+ const fields = meta.map((col, i) => {
1051
+ const src = sources && sources.length === meta.length ? sources[i] : void 0;
1052
+ const typeName = col.type?.declaration ?? "";
1053
+ return {
1054
+ name: col.name,
1055
+ orgName: src?.column || col.name,
1056
+ type: src?.type || typeName.toLowerCase(),
1057
+ table: src?.table ?? "",
1058
+ orgTable: src?.table ?? "",
1059
+ db: src?.schema ?? "",
1060
+ maxLength: typeof col.length === "number" ? col.length : 0
1061
+ };
1062
+ });
1063
+ const records = last.map((row) => row.map(normalizeValue));
1064
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1065
+ }
1066
+ return {
1067
+ fields: [],
1068
+ records: [],
1069
+ affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
1070
+ insertId: 0,
1071
+ isResultQuery: false
1072
+ };
1083
1073
  }
1084
- }
1085
- async function lookupHostKey(host, port, blob) {
1086
- const patterns = hostPatterns(host, port);
1087
- const type = keyType(blob);
1088
- const encoded = blob.toString("base64");
1089
- let sawType = false;
1090
- const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
1091
- for (const raw of lines) {
1092
- const line = raw.trim();
1093
- if (!line || line.startsWith("#") || line.startsWith("@") || line.startsWith("|")) continue;
1094
- const [hosts, lineType, lineKey] = line.split(/\s+/);
1095
- if (!hosts || !lineType || !lineKey) continue;
1096
- if (!hosts.toLowerCase().split(",").some((h) => patterns.includes(h))) continue;
1097
- if (lineType !== type) continue;
1098
- if (lineKey === encoded) return "match";
1099
- sawType = true;
1074
+ async close() {
1075
+ await this.pool.close();
1076
+ }
1077
+ };
1078
+ var mssqlDriverFactory = {
1079
+ async open(config) {
1080
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
1081
+ await pool.connect();
1082
+ return new MssqlDriver(pool, config);
1083
+ },
1084
+ async test(config) {
1085
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
1086
+ await pool.connect();
1087
+ await pool.close();
1088
+ },
1089
+ parseConfig(body) {
1090
+ const host = typeof body.host === "string" ? body.host.trim() : "";
1091
+ const user = typeof body.user === "string" ? body.user : "";
1092
+ if (!host || !user) {
1093
+ throw new Error("host and user are required");
1094
+ }
1095
+ return {
1096
+ driver: "mssql",
1097
+ host,
1098
+ port: Number(body.port) || 1433,
1099
+ user,
1100
+ password: typeof body.password === "string" ? body.password : "",
1101
+ database: typeof body.database === "string" && body.database ? body.database : "master",
1102
+ ssl: body.ssl === true,
1103
+ sslVerify: body.sslVerify === true
1104
+ };
1100
1105
  }
1101
- return sawType ? "mismatch" : "unknown";
1106
+ };
1107
+
1108
+ // src/drivers/oracle.ts
1109
+ import oracledb from "oracledb";
1110
+ oracledb.autoCommit = true;
1111
+ oracledb.fetchAsString = [oracledb.CLOB];
1112
+ var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
1113
+ var pad2 = (n, width = 2) => String(n).padStart(width, "0");
1114
+ function formatDate2(d) {
1115
+ const ms = d.getMilliseconds();
1116
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
1102
1117
  }
1103
- async function rememberHostKey(host, port, blob) {
1104
- await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
1105
- const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
1106
- await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
1107
- `, { mode: 384 });
1118
+ function normalizeValue2(value) {
1119
+ if (value instanceof Date) return formatDate2(value);
1120
+ if (Buffer.isBuffer(value)) return value.toString("hex");
1121
+ return value;
1108
1122
  }
1109
- function fingerprint(blob) {
1110
- return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
1123
+ var SESSION_FORMATS = [
1124
+ "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
1125
+ "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
1126
+ "ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
1127
+ ];
1128
+ var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
1129
+ var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
1130
+ function normalizeStatement(sql2) {
1131
+ const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
1132
+ if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
1133
+ return trimmed.replace(/;\s*$/, "");
1111
1134
  }
1112
- async function resolveConfig(ssh) {
1113
- const cfg = await resolveSshHost(ssh.host);
1114
- if (cfg.proxyJump) {
1115
- throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
1135
+ var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
1136
+ var START_TX = /^\s*SET\s+TRANSACTION\b/i;
1137
+ var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
1138
+ function singleSourceTable(sql2) {
1139
+ const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
1140
+ if (!m) return null;
1141
+ const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
1142
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
1143
+ const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
1144
+ return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
1145
+ }
1146
+ var OracleDriver = class {
1147
+ constructor(conn, config) {
1148
+ this.conn = conn;
1149
+ this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
1150
+ this.currentSchema = config.user.toUpperCase();
1116
1151
  }
1117
- const resolved = {
1118
- ...ssh,
1119
- host: cfg.hostName ?? ssh.host,
1120
- port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
1121
- user: ssh.user || cfg.user || ""
1122
- };
1123
- if (!resolved.user) {
1124
- throw new TunnelError(
1125
- SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
1126
- 400
1127
- );
1152
+ conn;
1153
+ type = "oracle";
1154
+ label;
1155
+ currentSchema;
1156
+ manualTx = false;
1157
+ /** Column names per `OWNER.TABLE`, for result-source resolution. */
1158
+ columns = /* @__PURE__ */ new Map();
1159
+ async init() {
1160
+ for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
1128
1161
  }
1129
- if (resolved.auth === "key" && !resolved.privateKey) {
1130
- if (SSH_CONFIG_DISABLED) {
1131
- throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
1162
+ async tableColumns(schema, table) {
1163
+ const key = `${schema}.${table}`;
1164
+ const cached = this.columns.get(key);
1165
+ if (cached !== void 0) return cached;
1166
+ let out = null;
1167
+ try {
1168
+ const res = await this.conn.execute(
1169
+ "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
1170
+ { o: schema, t: table },
1171
+ { outFormat: oracledb.OUT_FORMAT_ARRAY }
1172
+ );
1173
+ out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
1174
+ } catch {
1175
+ out = null;
1132
1176
  }
1133
- const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
1134
- for (const file of candidates) {
1135
- try {
1136
- resolved.privateKey = await readFile2(file, "utf8");
1137
- console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
1138
- break;
1139
- } catch {
1140
- }
1177
+ if (this.columns.size > 200) this.columns.clear();
1178
+ this.columns.set(key, out);
1179
+ return out;
1180
+ }
1181
+ async query(sql2, database) {
1182
+ if (database && database !== this.currentSchema) {
1183
+ await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
1184
+ this.currentSchema = database;
1141
1185
  }
1142
- if (!resolved.privateKey) {
1143
- throw new TunnelError(
1144
- `No private key given and none readable for ${ssh.host} (tried ${candidates.join(", ")})`,
1145
- 400
1146
- );
1186
+ const text = normalizeStatement(sql2);
1187
+ if (START_TX.test(text)) this.manualTx = true;
1188
+ const res = await this.conn.execute(text, [], {
1189
+ outFormat: oracledb.OUT_FORMAT_ARRAY,
1190
+ fetchTypeHandler,
1191
+ autoCommit: !this.manualTx
1192
+ });
1193
+ if (SET_SCHEMA.test(text)) this.currentSchema = null;
1194
+ if (END_TX.test(text)) this.manualTx = false;
1195
+ if (res.metaData && res.metaData.length > 0) {
1196
+ const source = singleSourceTable(text);
1197
+ const schema = source?.schema ?? this.currentSchema ?? "";
1198
+ const known = source && schema ? await this.tableColumns(schema, source.table) : null;
1199
+ const fields = res.metaData.map((col) => {
1200
+ const fromTable = known?.has(col.name) ?? false;
1201
+ return {
1202
+ name: col.name,
1203
+ orgName: col.name,
1204
+ type: (col.dbTypeName ?? "").toLowerCase(),
1205
+ table: fromTable ? source.table : "",
1206
+ orgTable: fromTable ? source.table : "",
1207
+ db: fromTable ? schema : "",
1208
+ maxLength: col.byteSize ?? 0
1209
+ };
1210
+ });
1211
+ const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
1212
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1147
1213
  }
1214
+ return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
1148
1215
  }
1149
- if (resolved.auth === "agent" && cfg.identityAgent && !process.env.SSH_AUTH_SOCK) {
1150
- process.env.SSH_AUTH_SOCK = cfg.identityAgent;
1216
+ async close() {
1217
+ await this.conn.close();
1151
1218
  }
1152
- return resolved;
1153
- }
1154
- function toConnectConfig(ssh) {
1155
- const base = {
1156
- host: ssh.host,
1157
- port: ssh.port,
1158
- username: ssh.user,
1159
- readyTimeout: CONNECT_TIMEOUT_MS,
1160
- keepaliveInterval: 3e4
1219
+ };
1220
+ function connectionAttributes(config) {
1221
+ return {
1222
+ user: config.user,
1223
+ password: config.password,
1224
+ connectString: `${config.host}:${config.port}/${config.serviceName}`
1161
1225
  };
1162
- switch (ssh.auth) {
1163
- case "password":
1164
- return { ...base, password: ssh.password ?? "", tryKeyboard: true };
1165
- case "key":
1166
- return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
1167
- case "agent": {
1168
- const agent = process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "pageant" : void 0);
1169
- if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
1170
- return { ...base, agent };
1226
+ }
1227
+ var oracleDriverFactory = {
1228
+ async open(config) {
1229
+ const conn = await oracledb.getConnection(connectionAttributes(config));
1230
+ const driver = new OracleDriver(conn, config);
1231
+ await driver.init();
1232
+ return driver;
1233
+ },
1234
+ async test(config) {
1235
+ const conn = await oracledb.getConnection(connectionAttributes(config));
1236
+ await conn.close();
1237
+ },
1238
+ parseConfig(body) {
1239
+ const host = typeof body.host === "string" ? body.host.trim() : "";
1240
+ const user = typeof body.user === "string" ? body.user : "";
1241
+ if (!host || !user) {
1242
+ throw new Error("host and user are required");
1171
1243
  }
1244
+ if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
1245
+ const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
1246
+ if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
1247
+ return {
1248
+ driver: "oracle",
1249
+ host,
1250
+ port: Number(body.port) || 1521,
1251
+ user,
1252
+ password: typeof body.password === "string" ? body.password : "",
1253
+ serviceName
1254
+ };
1255
+ }
1256
+ };
1257
+
1258
+ // src/drivers/d1.ts
1259
+ var API = "https://api.cloudflare.com/client/v4";
1260
+ var D1Error = class extends Error {
1261
+ constructor(message, status) {
1262
+ super(message);
1263
+ this.status = status;
1172
1264
  }
1265
+ status;
1266
+ };
1267
+ var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
1268
+ var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
1269
+ var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
1270
+ function singleSourceTable2(sql2) {
1271
+ const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
1272
+ if (!m) return null;
1273
+ const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
1274
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
1275
+ const raw = m[1];
1276
+ const first = raw[0];
1277
+ if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
1278
+ if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
1279
+ if (first === "[") return raw.slice(1, -1);
1280
+ return raw;
1281
+ }
1282
+ function normalizeDeclaredType2(declared) {
1283
+ if (typeof declared !== "string") return null;
1284
+ const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
1285
+ return base || null;
1173
1286
  }
1174
- function describeSshError(err, ssh) {
1175
- const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
1176
- if (err instanceof TunnelError) return err;
1177
- switch (err.level) {
1178
- case "client-authentication":
1179
- return new TunnelError(`SSH authentication failed for ${target}`, 401);
1180
- case "client-timeout":
1181
- return new TunnelError(`SSH connection to ${target} timed out`, 400);
1182
- default:
1183
- return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
1287
+ function inferTypeFromValue2(value) {
1288
+ if (value === null || value === void 0) return "null";
1289
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
1290
+ if (Array.isArray(value)) return "blob";
1291
+ return "text";
1292
+ }
1293
+ function firstNonNull2(records, index) {
1294
+ for (const row of records) {
1295
+ const value = row[index];
1296
+ if (value !== null && value !== void 0) return value;
1184
1297
  }
1298
+ return null;
1185
1299
  }
1186
- async function openSshTunnel(requested, target) {
1187
- const ssh = await resolveConfig(requested);
1188
- const client = new Client();
1189
- const connectConfig = toConnectConfig(ssh);
1190
- let verifyError;
1191
- connectConfig.hostVerifier = (key, verified) => {
1192
- void lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
1193
- if (result === "mismatch") {
1194
- verifyError = new TunnelError(
1195
- `Host key for ${ssh.host}:${ssh.port} has changed (${fingerprint(key)}). Remove the old entry from ~/.ssh/known_hosts or ${PLANCHE_KNOWN_HOSTS} if this is expected.`,
1196
- 400
1197
- );
1198
- verified(false);
1199
- return;
1200
- }
1201
- if (result === "unknown") {
1202
- console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
1203
- await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
1204
- }
1205
- verified(true);
1206
- });
1207
- };
1208
- await new Promise((resolve2, reject) => {
1209
- client.once("ready", resolve2);
1210
- client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
1300
+ var D1Driver = class {
1301
+ type = "d1";
1302
+ label;
1303
+ url;
1304
+ headers;
1305
+ /** Declared column types per table, for result-source resolution. */
1306
+ columns = /* @__PURE__ */ new Map();
1307
+ constructor(config) {
1308
+ this.label = `d1:${config.databaseId}`;
1309
+ this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
1310
+ this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
1311
+ }
1312
+ async raw(sql2) {
1313
+ let res;
1211
1314
  try {
1212
- client.connect(connectConfig);
1315
+ res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
1213
1316
  } catch (err) {
1214
- reject(describeSshError(err, ssh));
1317
+ throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
1215
1318
  }
1216
- });
1217
- const sockets = /* @__PURE__ */ new Set();
1218
- let forwardError;
1219
- const server = createServer((socket) => {
1220
- sockets.add(socket);
1221
- socket.once("close", () => sockets.delete(socket));
1222
- client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
1223
- if (err) {
1224
- const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
1225
- const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
1226
- forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
1227
- console.error(`[ssh] ${forwardError}`);
1228
- socket.destroy();
1229
- return;
1319
+ let body = null;
1320
+ try {
1321
+ body = await res.json();
1322
+ } catch {
1323
+ }
1324
+ if (!res.ok || !body?.success) {
1325
+ const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
1326
+ throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
1327
+ }
1328
+ const result = body.result?.[0];
1329
+ if (!result) throw new D1Error("Cloudflare API returned no result");
1330
+ return result;
1331
+ }
1332
+ async tableColumns(table) {
1333
+ const cached = this.columns.get(table);
1334
+ if (cached !== void 0) return cached;
1335
+ let out = null;
1336
+ try {
1337
+ const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
1338
+ const cols = info.results?.columns ?? [];
1339
+ const name = cols.indexOf("name");
1340
+ const type = cols.indexOf("type");
1341
+ const rows = info.results?.rows ?? [];
1342
+ if (rows.length > 0 && name >= 0) {
1343
+ out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
1230
1344
  }
1231
- socket.pipe(stream).pipe(socket);
1232
- stream.once("close", () => socket.destroy());
1233
- socket.once("close", () => stream.destroy());
1234
- });
1235
- });
1236
- await new Promise((resolve2, reject) => {
1237
- server.once("error", reject);
1238
- server.listen(0, "127.0.0.1", () => resolve2());
1239
- }).catch((err) => {
1240
- client.end();
1241
- throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
1242
- });
1243
- const { port } = server.address();
1244
- let closed = false;
1245
- const close = () => new Promise((resolve2) => {
1246
- if (closed) return resolve2();
1247
- closed = true;
1248
- for (const s of sockets) s.destroy();
1249
- server.close(() => {
1250
- client.end();
1251
- resolve2();
1345
+ } catch {
1346
+ out = null;
1347
+ }
1348
+ if (this.columns.size > 200) this.columns.clear();
1349
+ this.columns.set(table, out);
1350
+ return out;
1351
+ }
1352
+ async query(sql2) {
1353
+ if (DATABASE_LIST.test(sql2)) {
1354
+ return {
1355
+ fields: [
1356
+ { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
1357
+ { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
1358
+ { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
1359
+ ],
1360
+ records: [[0, "main", ""]],
1361
+ affectedRows: 0,
1362
+ insertId: 0,
1363
+ isResultQuery: true
1364
+ };
1365
+ }
1366
+ const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
1367
+ if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
1368
+ throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
1369
+ }
1370
+ if (this.isDdl(text)) this.columns.clear();
1371
+ const result = await this.raw(text);
1372
+ const columns = result.results?.columns ?? [];
1373
+ const records = result.results?.rows ?? [];
1374
+ if (columns.length === 0 && !RESULT_KIND.test(text)) {
1375
+ return {
1376
+ fields: [],
1377
+ records: [],
1378
+ affectedRows: result.meta?.changes ?? 0,
1379
+ insertId: result.meta?.last_row_id ?? 0,
1380
+ isResultQuery: false
1381
+ };
1382
+ }
1383
+ const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
1384
+ const known = source ? await this.tableColumns(source) : null;
1385
+ const fields = columns.map((name, index) => {
1386
+ const fromTable = known?.has(name) ?? false;
1387
+ const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
1388
+ return {
1389
+ name,
1390
+ orgName: name,
1391
+ type,
1392
+ table: fromTable && source ? source : "",
1393
+ orgTable: fromTable && source ? source : "",
1394
+ db: fromTable ? "main" : "",
1395
+ maxLength: 0
1396
+ };
1252
1397
  });
1253
- });
1254
- client.once("close", () => void close());
1255
- return {
1256
- host: "127.0.0.1",
1257
- port,
1258
- get forwardError() {
1259
- return forwardError;
1260
- },
1261
- close
1262
- };
1263
- }
1398
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1399
+ }
1400
+ isDdl(sql2) {
1401
+ return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
1402
+ }
1403
+ async close() {
1404
+ this.columns.clear();
1405
+ }
1406
+ };
1407
+ var d1DriverFactory = {
1408
+ async open(config) {
1409
+ const driver = new D1Driver(config);
1410
+ await driver.raw("SELECT 1");
1411
+ return driver;
1412
+ },
1413
+ async test(config) {
1414
+ await new D1Driver(config).raw("SELECT 1");
1415
+ },
1416
+ parseConfig(body) {
1417
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1418
+ const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1419
+ const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1420
+ if (!accountId || !databaseId || !apiToken) {
1421
+ throw new Error("accountId, databaseId and apiToken are required");
1422
+ }
1423
+ return { driver: "d1", accountId, databaseId, apiToken };
1424
+ }
1425
+ };
1264
1426
 
1265
1427
  // src/drivers/index.ts
1266
1428
  var factories = {
@@ -1360,6 +1522,7 @@ function testDirect(config) {
1360
1522
 
1361
1523
  // src/tunneling.ts
1362
1524
  var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
1525
+ var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
1363
1526
  var ConnectionManager = class {
1364
1527
  connections = /* @__PURE__ */ new Map();
1365
1528
  lastUsed = /* @__PURE__ */ new Map();
@@ -1381,6 +1544,9 @@ var ConnectionManager = class {
1381
1544
  * Open a new connection and return a unique connection ID.
1382
1545
  */
1383
1546
  async connect(config) {
1547
+ if (this.connections.size >= MAX_CONNECTIONS) {
1548
+ throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
1549
+ }
1384
1550
  const driver = await openDriver(config);
1385
1551
  const connectionId = randomUUID();
1386
1552
  this.connections.set(connectionId, driver);
@@ -1453,16 +1619,54 @@ function errorStatus(err) {
1453
1619
  const status = err?.status;
1454
1620
  return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
1455
1621
  }
1456
- function fail(res, status, message) {
1457
- res.status(status).json({ success: false, message });
1622
+ function fail(res, status, message, extra = {}) {
1623
+ res.status(status).json({ success: false, message, ...extra });
1624
+ }
1625
+ function errorExtra(err) {
1626
+ if (err instanceof HostKeyError) {
1627
+ const body = { code: "host-key-unknown", hostKey: err.hostKey };
1628
+ return body;
1629
+ }
1630
+ return {};
1458
1631
  }
1459
1632
  var errorMessage = (err) => err instanceof Error ? err.message : String(err);
1460
- function tokenMatches(header, expected) {
1461
- if (!header?.startsWith("Bearer ")) return false;
1462
- const given = Buffer.from(header.slice(7));
1463
- const want = Buffer.from(expected);
1464
- return given.length === want.length && timingSafeEqual(given, want);
1633
+ function secretEquals(given, expected) {
1634
+ const a = createHash2("sha256").update(given).digest();
1635
+ const b = createHash2("sha256").update(expected).digest();
1636
+ return timingSafeEqual2(a, b);
1637
+ }
1638
+ function bearerOf(header) {
1639
+ return header?.startsWith("Bearer ") ? header.slice(7) : null;
1465
1640
  }
1641
+ var AuthThrottle = class {
1642
+ constructor(max = 10, windowMs = 6e4, lockMs = 3e4) {
1643
+ this.max = max;
1644
+ this.windowMs = windowMs;
1645
+ this.lockMs = lockMs;
1646
+ }
1647
+ max;
1648
+ windowMs;
1649
+ lockMs;
1650
+ failures = /* @__PURE__ */ new Map();
1651
+ lockedUntil = /* @__PURE__ */ new Map();
1652
+ /** Keyed by peer address so one local process cannot lock the browser out. */
1653
+ locked(peer) {
1654
+ return Date.now() < (this.lockedUntil.get(peer) ?? 0);
1655
+ }
1656
+ fail(peer) {
1657
+ const now = Date.now();
1658
+ const recent = (this.failures.get(peer) ?? []).filter((t) => now - t < this.windowMs);
1659
+ recent.push(now);
1660
+ if (recent.length >= this.max) {
1661
+ this.lockedUntil.set(peer, now + this.lockMs);
1662
+ this.failures.delete(peer);
1663
+ console.warn(`[agent] Too many bad tokens from ${peer}; refusing its authentication for ${this.lockMs / 1e3}s`);
1664
+ } else {
1665
+ this.failures.set(peer, recent);
1666
+ }
1667
+ if (this.failures.size > 1e3) this.failures.clear();
1668
+ }
1669
+ };
1466
1670
  function accessControl(allowed) {
1467
1671
  return (req, res, next) => {
1468
1672
  const origin = req.headers.origin;
@@ -1496,6 +1700,22 @@ function createAgent(options) {
1496
1700
  const app = express();
1497
1701
  const manager = new ConnectionManager();
1498
1702
  const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
1703
+ const allowedHosts = options.allowedHosts ? new Set(options.allowedHosts.map((h) => h.toLowerCase())) : null;
1704
+ const throttle = new AuthThrottle();
1705
+ let pairing = options.pairingCode;
1706
+ app.set("env", "production");
1707
+ app.disable("x-powered-by");
1708
+ app.disable("etag");
1709
+ const peerOf = (req) => req.socket.remoteAddress ?? "unknown";
1710
+ const authorized = (req) => {
1711
+ const peer = peerOf(req);
1712
+ if (throttle.locked(peer)) return false;
1713
+ const given = bearerOf(req.headers.authorization);
1714
+ if (given === null) return false;
1715
+ const ok = secretEquals(given, options.authToken);
1716
+ if (!ok) throttle.fail(peer);
1717
+ return ok;
1718
+ };
1499
1719
  if (options.logRequests) {
1500
1720
  app.use((req, _res, next) => {
1501
1721
  const hasAuth = !!req.headers.authorization;
@@ -1503,19 +1723,19 @@ function createAgent(options) {
1503
1723
  next();
1504
1724
  });
1505
1725
  }
1506
- app.use(accessControl(allowedOrigins));
1507
- app.use(express.json({ limit: options.bodyLimit }));
1508
1726
  app.use((req, res, next) => {
1509
- if (req.path === "/health") {
1510
- next();
1511
- return;
1512
- }
1513
- if (!tokenMatches(req.headers.authorization, options.authToken)) {
1514
- fail(res, 401, "Unauthorized: invalid or missing auth token");
1515
- return;
1727
+ res.setHeader("Cache-Control", "no-store");
1728
+ res.setHeader("X-Content-Type-Options", "nosniff");
1729
+ if (allowedHosts) {
1730
+ const host = req.headers.host?.toLowerCase();
1731
+ if (!host || !allowedHosts.has(host)) {
1732
+ fail(res, 403, "Invalid Host header");
1733
+ return;
1734
+ }
1516
1735
  }
1517
1736
  next();
1518
1737
  });
1738
+ app.use(accessControl(allowedOrigins));
1519
1739
  app.get("/health", (req, res) => {
1520
1740
  const body = {
1521
1741
  success: true,
@@ -1526,10 +1746,41 @@ function createAgent(options) {
1526
1746
  uptime: process.uptime()
1527
1747
  };
1528
1748
  if (req.headers.authorization !== void 0) {
1529
- body.authorized = tokenMatches(req.headers.authorization, options.authToken);
1749
+ if (throttle.locked(peerOf(req))) {
1750
+ fail(res, 429, "Too many failed authentication attempts; try again shortly");
1751
+ return;
1752
+ }
1753
+ body.authorized = authorized(req);
1754
+ }
1755
+ res.json(body);
1756
+ });
1757
+ app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
1758
+ if (throttle.locked(peerOf(req))) {
1759
+ fail(res, 429, "Too many failed authentication attempts; try again shortly");
1760
+ return;
1761
+ }
1762
+ const code = typeof req.body?.code === "string" ? req.body.code : "";
1763
+ if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
1764
+ throttle.fail(peerOf(req));
1765
+ fail(res, 401, "Pairing code is invalid or has expired; restart the agent for a new link");
1766
+ return;
1530
1767
  }
1768
+ pairing = void 0;
1769
+ const body = { success: true, token: options.authToken };
1531
1770
  res.json(body);
1532
1771
  });
1772
+ app.use((req, res, next) => {
1773
+ if (throttle.locked(peerOf(req))) {
1774
+ fail(res, 429, "Too many failed authentication attempts; try again shortly");
1775
+ return;
1776
+ }
1777
+ if (!authorized(req)) {
1778
+ fail(res, 401, "Unauthorized: invalid or missing auth token");
1779
+ return;
1780
+ }
1781
+ next();
1782
+ });
1783
+ app.use(express.json({ limit: options.bodyLimit }));
1533
1784
  app.post("/connect", async (req, res) => {
1534
1785
  try {
1535
1786
  const config = parseDriverConfig(req.body ?? {});
@@ -1538,12 +1789,12 @@ function createAgent(options) {
1538
1789
  } catch (err) {
1539
1790
  const message = errorMessage(err);
1540
1791
  console.error("[connect] Error:", message);
1541
- fail(res, errorStatus(err), message);
1792
+ fail(res, errorStatus(err), message, errorExtra(err));
1542
1793
  }
1543
1794
  });
1544
1795
  app.post("/query", async (req, res) => {
1545
1796
  try {
1546
- const { connectionId, sql: sql2, database } = req.body;
1797
+ const { connectionId, sql: sql2, database } = req.body ?? {};
1547
1798
  if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
1548
1799
  fail(res, 400, "connectionId and sql are required");
1549
1800
  return;
@@ -1553,7 +1804,7 @@ function createAgent(options) {
1553
1804
  return;
1554
1805
  }
1555
1806
  const start = Date.now();
1556
- const result = await manager.query(connectionId, sql2, database);
1807
+ const result = await manager.query(connectionId, sql2, typeof database === "string" ? database : void 0);
1557
1808
  const execTime = Date.now() - start;
1558
1809
  res.json({
1559
1810
  success: true,
@@ -1572,7 +1823,7 @@ function createAgent(options) {
1572
1823
  });
1573
1824
  app.post("/disconnect", async (req, res) => {
1574
1825
  try {
1575
- const { connectionId } = req.body;
1826
+ const { connectionId } = req.body ?? {};
1576
1827
  if (typeof connectionId !== "string" || !connectionId) {
1577
1828
  fail(res, 400, "connectionId is required");
1578
1829
  return;
@@ -1596,9 +1847,17 @@ function createAgent(options) {
1596
1847
  } catch (err) {
1597
1848
  const message = errorMessage(err);
1598
1849
  console.error("[test-connection] Error:", message);
1599
- fail(res, errorStatus(err), message);
1850
+ fail(res, errorStatus(err), message, errorExtra(err));
1600
1851
  }
1601
1852
  });
1853
+ app.use((_req, res) => fail(res, 404, "Not found"));
1854
+ app.use((err, _req, res, _next) => {
1855
+ const e = err;
1856
+ const status = e?.type === "entity.too.large" ? 413 : typeof e?.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 400;
1857
+ console.error("[agent] Request error:", e?.message ?? String(err));
1858
+ if (res.headersSent) return;
1859
+ fail(res, status, status === 413 ? "Request body too large" : status === 400 ? "Malformed request" : "Request failed");
1860
+ });
1602
1861
  return { app, manager };
1603
1862
  }
1604
1863
 
@@ -1623,6 +1882,7 @@ Options:
1623
1882
  --app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
1624
1883
  --origin <url> Extra browser origin allowed to use the agent (repeatable,
1625
1884
  env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
1885
+ --allow-remote Required with a non-loopback --addr (see README: no TLS)
1626
1886
  --no-open Do not open the browser
1627
1887
  --verbose Log every request
1628
1888
  -v, --version Print the version
@@ -1634,15 +1894,21 @@ function splitList(value) {
1634
1894
  }
1635
1895
  function originOf(url) {
1636
1896
  try {
1637
- return new URL(url).origin;
1897
+ const parsed = new URL(url);
1898
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1899
+ return parsed.origin === "null" ? null : parsed.origin;
1638
1900
  } catch {
1639
1901
  return null;
1640
1902
  }
1641
1903
  }
1904
+ var LOOPBACK_ADDRS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
1905
+ var MIN_TOKEN_LENGTH = 16;
1642
1906
  function openInBrowser(url) {
1643
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
1907
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1644
1908
  try {
1645
- spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
1909
+ const env = { ...process.env };
1910
+ delete env.PLANCHE_AUTH_TOKEN;
1911
+ spawn(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
1646
1912
  } catch {
1647
1913
  }
1648
1914
  }
@@ -1664,6 +1930,7 @@ function main() {
1664
1930
  origin: { type: "string", multiple: true },
1665
1931
  open: { type: "boolean" },
1666
1932
  verbose: { type: "boolean" },
1933
+ "allow-remote": { type: "boolean" },
1667
1934
  version: { type: "boolean", short: "v" },
1668
1935
  help: { type: "boolean", short: "h" }
1669
1936
  },
@@ -1693,37 +1960,61 @@ function main() {
1693
1960
  const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
1694
1961
  const appOrigin = originOf(appUrl);
1695
1962
  if (!appOrigin) {
1696
- console.error(`Invalid --app URL: ${appUrl}`);
1963
+ console.error(`Invalid --app URL (http or https only): ${appUrl}`);
1964
+ process.exit(2);
1965
+ }
1966
+ const loopback = LOOPBACK_ADDRS.has(addr);
1967
+ if (!loopback) {
1968
+ if (!flags["allow-remote"]) {
1969
+ console.error(`Refusing to bind to ${addr}: the agent speaks plain HTTP, so database passwords and SSH keys would cross the network unencrypted.`);
1970
+ console.error("Put it behind a TLS reverse proxy or an SSH port forward, then pass --allow-remote together with --token (or PLANCHE_AUTH_TOKEN).");
1971
+ process.exit(2);
1972
+ }
1973
+ if (!flags.token && !process.env.PLANCHE_AUTH_TOKEN) {
1974
+ console.error("--allow-remote needs an explicit --token / PLANCHE_AUTH_TOKEN.");
1975
+ process.exit(2);
1976
+ }
1977
+ }
1978
+ if ((flags.token || process.env.PLANCHE_AUTH_TOKEN) && authToken.length < MIN_TOKEN_LENGTH) {
1979
+ console.error(`The auth token must be at least ${MIN_TOKEN_LENGTH} characters (got ${authToken.length}). Generate one with: openssl rand -base64 24`);
1697
1980
  process.exit(2);
1698
1981
  }
1699
- const allowedOrigins = [
1700
- appOrigin,
1701
- ...DEV_ORIGINS,
1702
- ...flags.origin ?? [],
1703
- ...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)
1704
- ];
1982
+ const extraOrigins = [...flags.origin ?? [], ...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)];
1983
+ for (const o of extraOrigins) {
1984
+ if (originOf(o) !== o.replace(/\/+$/, "")) {
1985
+ console.error(`Invalid --origin (expected scheme://host[:port]): ${o}`);
1986
+ process.exit(2);
1987
+ }
1988
+ }
1989
+ const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
1990
+ const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
1991
+ const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
1992
+ const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
1993
+ const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
1705
1994
  const { app, manager } = createAgent({
1706
1995
  authToken,
1707
1996
  allowedOrigins,
1708
1997
  bodyLimit,
1709
1998
  version: VERSION,
1710
- logRequests: flags.verbose ?? false
1999
+ logRequests: flags.verbose ?? false,
2000
+ // Browsers omit a default port from `Host`, so the bare names are allowed too.
2001
+ allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
2002
+ pairingCode
1711
2003
  });
1712
- const agentUrl = `http://${addr}:${port}`;
1713
- const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&token=${encodeURIComponent(authToken)}`;
1714
- const server = app.listen(port, addr, () => {
2004
+ const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
2005
+ const server = app.listen(port, listenAddr, () => {
1715
2006
  const line = "-".repeat(72);
1716
2007
  console.log(line);
1717
2008
  console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
1718
2009
  console.log(line);
1719
- console.log(`Open the app with:
2010
+ console.log(`Open the app with (link valid for 10 minutes, single use):
1720
2011
 
1721
2012
  ${pairUrl}
1722
2013
  `);
1723
- console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token: ${authToken}`);
2014
+ console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token (for manual setup): ${authToken}`);
1724
2015
  console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
1725
- if (!["127.0.0.1", "localhost", "::1"].includes(addr)) {
1726
- console.log(`WARNING: bound to ${addr} \u2014 anyone who can reach this port and knows the token can use your databases.`);
2016
+ if (!loopback) {
2017
+ console.error(`WARNING: bound to ${addr} without TLS \u2014 anyone who can reach this port and knows the token can use your databases, and anyone on the path can read the traffic.`);
1727
2018
  }
1728
2019
  console.log(line);
1729
2020
  if (shouldOpen) openInBrowser(pairUrl);
@@ -1743,5 +2034,7 @@ function main() {
1743
2034
  };
1744
2035
  process.on("SIGINT", shutdown);
1745
2036
  process.on("SIGTERM", shutdown);
2037
+ process.on("uncaughtException", (err) => console.error("[agent] Uncaught exception:", err instanceof Error ? err.message : String(err)));
2038
+ process.on("unhandledRejection", (err) => console.error("[agent] Unhandled rejection:", err instanceof Error ? err.message : String(err)));
1746
2039
  }
1747
2040
  main();