planche 0.4.1 → 0.5.0

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