planche 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -17
- package/dist/index.js +703 -19
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# planche
|
|
2
2
|
|
|
3
3
|
Local agent for [Planche](https://planche.dev), a browser-based SQL client for
|
|
4
|
-
MySQL, MariaDB, PostgreSQL and
|
|
4
|
+
MySQL, MariaDB, PostgreSQL, SQLite, SQL Server and Oracle.
|
|
5
5
|
|
|
6
6
|
Browsers cannot open TCP connections to a database, so Planche runs this small
|
|
7
7
|
agent on your machine. The app in your browser talks to the agent over
|
|
@@ -39,8 +39,6 @@ npx planche [options]
|
|
|
39
39
|
-p, --port <n> Port to listen on (default: 8888)
|
|
40
40
|
--addr <host> Address to bind (default: 127.0.0.1)
|
|
41
41
|
--token <str> Auth token (default: random on each start)
|
|
42
|
-
--app <url> Web app to open (default: https://planche.dev)
|
|
43
|
-
--origin <url> Extra browser origin allowed to use the agent (repeatable)
|
|
44
42
|
--no-open Print the pairing link without opening a browser
|
|
45
43
|
--verbose Log every request
|
|
46
44
|
-h, --help Show help
|
|
@@ -53,10 +51,9 @@ Every option can also be set through an environment variable:
|
|
|
53
51
|
| `PLANCHE_PORT` | `--port` | |
|
|
54
52
|
| `PLANCHE_ADDR` | `--addr` | |
|
|
55
53
|
| `PLANCHE_AUTH_TOKEN` | `--token` | Fixed token; the pairing link stays valid across restarts |
|
|
56
|
-
| `PLANCHE_APP_URL` | `--app` | Use a self-hosted build of the app |
|
|
57
|
-
| `PLANCHE_ALLOWED_ORIGINS` | `--origin` | Comma-separated list |
|
|
58
54
|
| `PLANCHE_BODY_LIMIT` | | Max request body, default `50mb` (large SQL scripts) |
|
|
59
55
|
| `PLANCHE_SQLITE_DIR` | | Only SQLite files inside this directory may be opened |
|
|
56
|
+
| `PLANCHE_SSH_CONFIG` | `~/.ssh/config` | SSH client config used to resolve jump-host aliases; `none` disables reading it and `~/.ssh/id_*` |
|
|
60
57
|
|
|
61
58
|
### Examples
|
|
62
59
|
|
|
@@ -72,12 +69,6 @@ Run on another port and print the link only:
|
|
|
72
69
|
npx planche --port 9000 --no-open
|
|
73
70
|
```
|
|
74
71
|
|
|
75
|
-
Use with the app served from your own host:
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
npx planche --app https://planche.example.com
|
|
79
|
-
```
|
|
80
|
-
|
|
81
72
|
Restrict SQLite access to one folder:
|
|
82
73
|
|
|
83
74
|
```bash
|
|
@@ -99,19 +90,42 @@ database connections open and forwards queries with the matching driver
|
|
|
99
90
|
- **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
|
|
100
91
|
limit which files can be opened. (Small SQLite files can also be opened
|
|
101
92
|
directly in the browser without the agent.)
|
|
93
|
+
- **SQL Server** — same model as PostgreSQL: the connection is bound to one
|
|
94
|
+
database and the app's "database" is a schema (`dbo`, ...). `USE` works in
|
|
95
|
+
the editor because every statement runs on the same session; scripts may
|
|
96
|
+
contain `GO` lines.
|
|
97
|
+
- **Oracle** — thin-mode driver, no Instant Client needed. Schemas (users) are
|
|
98
|
+
the app's "databases", switched with `ALTER SESSION SET CURRENT_SCHEMA`.
|
|
99
|
+
PL/SQL blocks end with a `/` line as in SQL*Plus.
|
|
100
|
+
- **SSH tunnel** — MySQL, PostgreSQL, SQL Server and Oracle profiles can go through an SSH jump
|
|
101
|
+
host (password, private key, or the local `ssh-agent`). The agent opens the
|
|
102
|
+
tunnel with `ssh2` on a loopback port and points the driver at it. The host
|
|
103
|
+
may be a `Host` alias from `~/.ssh/config`: `HostName`, `Port`, `User` and
|
|
104
|
+
`IdentityFile` are applied like the `ssh` command does (`ProxyJump` is not
|
|
105
|
+
supported), and with no key given `~/.ssh/id_*` are tried. Unknown
|
|
106
|
+
host keys are trusted on first use and recorded in `~/.planche/known_hosts`
|
|
107
|
+
(entries in `~/.ssh/known_hosts` are honoured too); a changed key refuses the
|
|
108
|
+
connection until the stale entry is removed.
|
|
102
109
|
|
|
103
110
|
## Security
|
|
104
111
|
|
|
105
112
|
- Every request except `GET /health` requires the bearer token.
|
|
106
|
-
- Browser requests are accepted only from the Planche app origin
|
|
107
|
-
dev
|
|
108
|
-
|
|
113
|
+
- Browser requests are accepted only from the Planche app origin
|
|
114
|
+
(`https://planche.dev`). Any other `Origin` is refused with `403`, even
|
|
115
|
+
with a valid token.
|
|
109
116
|
- The agent binds to `127.0.0.1` by default, so only the browser on the same
|
|
110
117
|
machine can reach it. Binding to another address (`--addr 0.0.0.0`) exposes
|
|
111
118
|
your databases to the network — only do that behind a tunnel or firewall you
|
|
112
119
|
trust.
|
|
113
120
|
- The agent never stores credentials. They are passed per connection by the
|
|
114
|
-
app
|
|
121
|
+
app (including SSH passwords and private key contents) and live in memory
|
|
122
|
+
only while the connection is open. The only thing written to disk is the
|
|
123
|
+
SSH host key list in `~/.planche/known_hosts`.
|
|
124
|
+
- For SSH tunnels the agent reads `~/.ssh/config` and, when the profile has no
|
|
125
|
+
key, the `IdentityFile` it names or `~/.ssh/id_*` — exactly what the `ssh`
|
|
126
|
+
command would do, and the key never leaves the agent. Set
|
|
127
|
+
`PLANCHE_SSH_CONFIG=none` if you would rather the agent only used what the
|
|
128
|
+
app sends.
|
|
115
129
|
|
|
116
130
|
## Troubleshooting
|
|
117
131
|
|
|
@@ -120,8 +134,8 @@ database connections open and forwards queries with the matching driver
|
|
|
120
134
|
browser extensions that block `localhost` requests can also cause this.
|
|
121
135
|
- **"Agent restarted, re-pair"** — the agent generated a new token. Open the
|
|
122
136
|
new link it printed, or start it with a fixed `--token`.
|
|
123
|
-
- **`403 Forbidden`** — the request
|
|
124
|
-
|
|
137
|
+
- **`403 Forbidden`** — the request did not come from `https://planche.dev`.
|
|
138
|
+
Open the app from the pairing link the agent printed.
|
|
125
139
|
- **Wrong Node version** — the SQLite driver ships prebuilt binaries for
|
|
126
140
|
Node 22+. Check with `node -v`, or run with `npx -p node@22 planche`.
|
|
127
141
|
|
package/dist/index.js
CHANGED
|
@@ -80,13 +80,13 @@ var MySqlDriver = class {
|
|
|
80
80
|
* dropping temporary tables and user variables.
|
|
81
81
|
*/
|
|
82
82
|
currentDatabase;
|
|
83
|
-
async query(
|
|
83
|
+
async query(sql2, database) {
|
|
84
84
|
if (database && database !== this.currentDatabase) {
|
|
85
85
|
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
86
86
|
this.currentDatabase = database;
|
|
87
87
|
}
|
|
88
|
-
const [results, fields] = await this.connection.query(
|
|
89
|
-
if (USE_STATEMENT.test(
|
|
88
|
+
const [results, fields] = await this.connection.query(sql2);
|
|
89
|
+
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
90
90
|
if (Array.isArray(results) && fields) {
|
|
91
91
|
const fieldPackets = fields;
|
|
92
92
|
const mappedFields = fieldPackets.map((field) => ({
|
|
@@ -269,13 +269,13 @@ var PostgresDriver = class {
|
|
|
269
269
|
} catch {
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
-
async query(
|
|
272
|
+
async query(sql2, database) {
|
|
273
273
|
if (database && database !== this.currentSchema) {
|
|
274
274
|
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
275
275
|
this.currentSchema = database;
|
|
276
276
|
}
|
|
277
|
-
const result = await this.client.query({ text:
|
|
278
|
-
if (SEARCH_PATH_STATEMENT.test(
|
|
277
|
+
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
278
|
+
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
279
279
|
const results = Array.isArray(result) ? result : [result];
|
|
280
280
|
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
281
281
|
if (last.fields.length > 0) {
|
|
@@ -385,13 +385,13 @@ var SqliteDriver = class {
|
|
|
385
385
|
db;
|
|
386
386
|
type = "sqlite";
|
|
387
387
|
label;
|
|
388
|
-
async query(
|
|
388
|
+
async query(sql2) {
|
|
389
389
|
let statement;
|
|
390
390
|
try {
|
|
391
|
-
statement = this.db.prepare(
|
|
391
|
+
statement = this.db.prepare(sql2);
|
|
392
392
|
} catch (err) {
|
|
393
393
|
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
394
|
-
this.db.exec(
|
|
394
|
+
this.db.exec(sql2);
|
|
395
395
|
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
396
396
|
}
|
|
397
397
|
throw err;
|
|
@@ -466,11 +466,639 @@ var sqliteDriverFactory = {
|
|
|
466
466
|
}
|
|
467
467
|
};
|
|
468
468
|
|
|
469
|
+
// src/drivers/mssql.ts
|
|
470
|
+
import sql from "mssql";
|
|
471
|
+
function toPoolConfig(config) {
|
|
472
|
+
return {
|
|
473
|
+
server: config.host,
|
|
474
|
+
port: config.port,
|
|
475
|
+
user: config.user,
|
|
476
|
+
password: config.password,
|
|
477
|
+
database: config.database,
|
|
478
|
+
// One physical connection: manual transactions and USE need session affinity.
|
|
479
|
+
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
480
|
+
options: {
|
|
481
|
+
encrypt: config.ssl === true,
|
|
482
|
+
trustServerCertificate: true,
|
|
483
|
+
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
484
|
+
useUTC: false,
|
|
485
|
+
enableArithAbort: true
|
|
486
|
+
},
|
|
487
|
+
requestTimeout: 0
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
491
|
+
function formatDate(d) {
|
|
492
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
493
|
+
const ms = d.getMilliseconds();
|
|
494
|
+
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
|
|
495
|
+
}
|
|
496
|
+
function normalizeValue(value) {
|
|
497
|
+
if (value instanceof Date) return formatDate(value);
|
|
498
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
502
|
+
var MssqlDriver = class {
|
|
503
|
+
constructor(pool, config) {
|
|
504
|
+
this.pool = pool;
|
|
505
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
506
|
+
}
|
|
507
|
+
pool;
|
|
508
|
+
type = "mssql";
|
|
509
|
+
label;
|
|
510
|
+
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
511
|
+
sources = /* @__PURE__ */ new Map();
|
|
512
|
+
/**
|
|
513
|
+
* The `mssql` driver does not report which table a result column comes
|
|
514
|
+
* from; ask the server to describe the statement instead so the grid can
|
|
515
|
+
* decide whether it is editable and address rows by primary key.
|
|
516
|
+
*/
|
|
517
|
+
async describe(text) {
|
|
518
|
+
const cached = this.sources.get(text);
|
|
519
|
+
if (cached !== void 0) return cached;
|
|
520
|
+
let result = null;
|
|
521
|
+
try {
|
|
522
|
+
const req = this.pool.request();
|
|
523
|
+
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
524
|
+
const res = await req.query(
|
|
525
|
+
"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"
|
|
526
|
+
);
|
|
527
|
+
result = res.recordset.map((r) => ({
|
|
528
|
+
schema: r.source_schema ?? "",
|
|
529
|
+
table: r.source_table ?? "",
|
|
530
|
+
column: r.source_column ?? "",
|
|
531
|
+
type: (r.system_type_name ?? "").toLowerCase()
|
|
532
|
+
}));
|
|
533
|
+
} catch {
|
|
534
|
+
result = null;
|
|
535
|
+
}
|
|
536
|
+
if (this.sources.size > 200) this.sources.clear();
|
|
537
|
+
this.sources.set(text, result);
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
async query(text) {
|
|
541
|
+
const req = this.pool.request();
|
|
542
|
+
req.arrayRowMode = true;
|
|
543
|
+
const res = await req.query(text);
|
|
544
|
+
const sets = res.recordsets;
|
|
545
|
+
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
546
|
+
const columns = last?.columns;
|
|
547
|
+
if (last && columns) {
|
|
548
|
+
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
549
|
+
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
550
|
+
const fields = meta.map((col, i) => {
|
|
551
|
+
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
552
|
+
const typeName = col.type?.declaration ?? "";
|
|
553
|
+
return {
|
|
554
|
+
name: col.name,
|
|
555
|
+
orgName: src?.column || col.name,
|
|
556
|
+
type: src?.type || typeName.toLowerCase(),
|
|
557
|
+
table: src?.table ?? "",
|
|
558
|
+
orgTable: src?.table ?? "",
|
|
559
|
+
db: src?.schema ?? "",
|
|
560
|
+
maxLength: typeof col.length === "number" ? col.length : 0
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
const records = last.map((row) => row.map(normalizeValue));
|
|
564
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
fields: [],
|
|
568
|
+
records: [],
|
|
569
|
+
affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
|
|
570
|
+
insertId: 0,
|
|
571
|
+
isResultQuery: false
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async close() {
|
|
575
|
+
await this.pool.close();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
var mssqlDriverFactory = {
|
|
579
|
+
async open(config) {
|
|
580
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
581
|
+
await pool.connect();
|
|
582
|
+
return new MssqlDriver(pool, config);
|
|
583
|
+
},
|
|
584
|
+
async test(config) {
|
|
585
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
586
|
+
await pool.connect();
|
|
587
|
+
await pool.close();
|
|
588
|
+
},
|
|
589
|
+
parseConfig(body) {
|
|
590
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
591
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
592
|
+
if (!host || !user) {
|
|
593
|
+
throw new Error("host and user are required");
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
driver: "mssql",
|
|
597
|
+
host,
|
|
598
|
+
port: Number(body.port) || 1433,
|
|
599
|
+
user,
|
|
600
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
601
|
+
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
602
|
+
ssl: body.ssl === true
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// src/drivers/oracle.ts
|
|
608
|
+
import oracledb from "oracledb";
|
|
609
|
+
oracledb.autoCommit = true;
|
|
610
|
+
oracledb.fetchAsString = [oracledb.CLOB];
|
|
611
|
+
var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
|
|
612
|
+
var pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
613
|
+
function formatDate2(d) {
|
|
614
|
+
const ms = d.getMilliseconds();
|
|
615
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
|
|
616
|
+
}
|
|
617
|
+
function normalizeValue2(value) {
|
|
618
|
+
if (value instanceof Date) return formatDate2(value);
|
|
619
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
var SESSION_FORMATS = [
|
|
623
|
+
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
|
|
624
|
+
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
|
|
625
|
+
"ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
|
|
626
|
+
];
|
|
627
|
+
var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
628
|
+
var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
|
|
629
|
+
function normalizeStatement(sql2) {
|
|
630
|
+
const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
|
|
631
|
+
if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
632
|
+
return trimmed.replace(/;\s*$/, "");
|
|
633
|
+
}
|
|
634
|
+
var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
|
|
635
|
+
var START_TX = /^\s*SET\s+TRANSACTION\b/i;
|
|
636
|
+
var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
|
|
637
|
+
function singleSourceTable(sql2) {
|
|
638
|
+
const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
|
|
639
|
+
if (!m) return null;
|
|
640
|
+
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, "");
|
|
641
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
642
|
+
const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
|
|
643
|
+
return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
|
|
644
|
+
}
|
|
645
|
+
var OracleDriver = class {
|
|
646
|
+
constructor(conn, config) {
|
|
647
|
+
this.conn = conn;
|
|
648
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
649
|
+
this.currentSchema = config.user.toUpperCase();
|
|
650
|
+
}
|
|
651
|
+
conn;
|
|
652
|
+
type = "oracle";
|
|
653
|
+
label;
|
|
654
|
+
currentSchema;
|
|
655
|
+
manualTx = false;
|
|
656
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
657
|
+
columns = /* @__PURE__ */ new Map();
|
|
658
|
+
async init() {
|
|
659
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
660
|
+
}
|
|
661
|
+
async tableColumns(schema, table) {
|
|
662
|
+
const key = `${schema}.${table}`;
|
|
663
|
+
const cached = this.columns.get(key);
|
|
664
|
+
if (cached !== void 0) return cached;
|
|
665
|
+
let out = null;
|
|
666
|
+
try {
|
|
667
|
+
const res = await this.conn.execute(
|
|
668
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
669
|
+
{ o: schema, t: table },
|
|
670
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
671
|
+
);
|
|
672
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
673
|
+
} catch {
|
|
674
|
+
out = null;
|
|
675
|
+
}
|
|
676
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
677
|
+
this.columns.set(key, out);
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
async query(sql2, database) {
|
|
681
|
+
if (database && database !== this.currentSchema) {
|
|
682
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
683
|
+
this.currentSchema = database;
|
|
684
|
+
}
|
|
685
|
+
const text = normalizeStatement(sql2);
|
|
686
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
687
|
+
const res = await this.conn.execute(text, [], {
|
|
688
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
689
|
+
fetchTypeHandler,
|
|
690
|
+
autoCommit: !this.manualTx
|
|
691
|
+
});
|
|
692
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
693
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
694
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
695
|
+
const source = singleSourceTable(text);
|
|
696
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
697
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
698
|
+
const fields = res.metaData.map((col) => {
|
|
699
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
700
|
+
return {
|
|
701
|
+
name: col.name,
|
|
702
|
+
orgName: col.name,
|
|
703
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
704
|
+
table: fromTable ? source.table : "",
|
|
705
|
+
orgTable: fromTable ? source.table : "",
|
|
706
|
+
db: fromTable ? schema : "",
|
|
707
|
+
maxLength: col.byteSize ?? 0
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
711
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
712
|
+
}
|
|
713
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
714
|
+
}
|
|
715
|
+
async close() {
|
|
716
|
+
await this.conn.close();
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
function connectionAttributes(config) {
|
|
720
|
+
return {
|
|
721
|
+
user: config.user,
|
|
722
|
+
password: config.password,
|
|
723
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
var oracleDriverFactory = {
|
|
727
|
+
async open(config) {
|
|
728
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
729
|
+
const driver = new OracleDriver(conn, config);
|
|
730
|
+
await driver.init();
|
|
731
|
+
return driver;
|
|
732
|
+
},
|
|
733
|
+
async test(config) {
|
|
734
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
735
|
+
await conn.close();
|
|
736
|
+
},
|
|
737
|
+
parseConfig(body) {
|
|
738
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
739
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
740
|
+
if (!host || !user) {
|
|
741
|
+
throw new Error("host and user are required");
|
|
742
|
+
}
|
|
743
|
+
return {
|
|
744
|
+
driver: "oracle",
|
|
745
|
+
host,
|
|
746
|
+
port: Number(body.port) || 1521,
|
|
747
|
+
user,
|
|
748
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
749
|
+
serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// src/ssh-tunnel.ts
|
|
755
|
+
import { createServer } from "net";
|
|
756
|
+
import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
|
|
757
|
+
import { createHash } from "crypto";
|
|
758
|
+
import { homedir as homedir2 } from "os";
|
|
759
|
+
import { join as join2 } from "path";
|
|
760
|
+
import { Client } from "ssh2";
|
|
761
|
+
|
|
762
|
+
// src/ssh-config.ts
|
|
763
|
+
import { readFile } from "fs/promises";
|
|
764
|
+
import { homedir } from "os";
|
|
765
|
+
import { dirname, isAbsolute as isAbsolute2, join } from "path";
|
|
766
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
767
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
|
|
768
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
769
|
+
(f) => join(homedir(), ".ssh", f)
|
|
770
|
+
);
|
|
771
|
+
function expandHome(path) {
|
|
772
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
773
|
+
}
|
|
774
|
+
function globToRegExp(pattern) {
|
|
775
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
776
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
777
|
+
}
|
|
778
|
+
function hostPatternMatches(patterns, host) {
|
|
779
|
+
let matched = false;
|
|
780
|
+
for (const raw of patterns) {
|
|
781
|
+
const negated = raw.startsWith("!");
|
|
782
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
783
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
784
|
+
if (negated) return false;
|
|
785
|
+
matched = true;
|
|
786
|
+
}
|
|
787
|
+
return matched;
|
|
788
|
+
}
|
|
789
|
+
function tokenize(line) {
|
|
790
|
+
const tokens = [];
|
|
791
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
792
|
+
let m;
|
|
793
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
794
|
+
return tokens;
|
|
795
|
+
}
|
|
796
|
+
function parseLine(line) {
|
|
797
|
+
const trimmed = line.trim();
|
|
798
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
799
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
800
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
801
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
802
|
+
}
|
|
803
|
+
async function loadLines(file, seen) {
|
|
804
|
+
if (seen.has(file)) return [];
|
|
805
|
+
seen.add(file);
|
|
806
|
+
try {
|
|
807
|
+
return (await readFile(file, "utf8")).split(/\r?\n/);
|
|
808
|
+
} catch {
|
|
809
|
+
return [];
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
813
|
+
const result = { identityFiles: [] };
|
|
814
|
+
if (!file) return result;
|
|
815
|
+
const seen = /* @__PURE__ */ new Set();
|
|
816
|
+
async function walk(path, active) {
|
|
817
|
+
let blockActive = active;
|
|
818
|
+
for (const line of await loadLines(path, seen)) {
|
|
819
|
+
const d = parseLine(line);
|
|
820
|
+
if (!d) continue;
|
|
821
|
+
switch (d.keyword) {
|
|
822
|
+
case "host":
|
|
823
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
824
|
+
continue;
|
|
825
|
+
case "match":
|
|
826
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
827
|
+
continue;
|
|
828
|
+
case "include":
|
|
829
|
+
for (const inc of d.args) {
|
|
830
|
+
const target = expandHome(inc);
|
|
831
|
+
await walk(isAbsolute2(target) ? target : join(dirname(file), target), blockActive);
|
|
832
|
+
}
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (!blockActive) continue;
|
|
836
|
+
const value = d.args[0];
|
|
837
|
+
if (value === void 0) continue;
|
|
838
|
+
switch (d.keyword) {
|
|
839
|
+
case "hostname":
|
|
840
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
841
|
+
break;
|
|
842
|
+
case "port":
|
|
843
|
+
result.port ??= Number(value) || void 0;
|
|
844
|
+
break;
|
|
845
|
+
case "user":
|
|
846
|
+
result.user ??= value;
|
|
847
|
+
break;
|
|
848
|
+
case "identityfile":
|
|
849
|
+
for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
|
|
850
|
+
break;
|
|
851
|
+
case "identityagent":
|
|
852
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
|
|
853
|
+
break;
|
|
854
|
+
case "proxyjump":
|
|
855
|
+
result.proxyJump ??= value;
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
await walk(file, true);
|
|
861
|
+
return result;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/ssh-tunnel.ts
|
|
865
|
+
var TunnelError = class extends Error {
|
|
866
|
+
constructor(message, status) {
|
|
867
|
+
super(message);
|
|
868
|
+
this.status = status;
|
|
869
|
+
this.name = "TunnelError";
|
|
870
|
+
}
|
|
871
|
+
status;
|
|
872
|
+
};
|
|
873
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
874
|
+
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
875
|
+
var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
|
|
876
|
+
var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
|
|
877
|
+
function parseSshConfig(value) {
|
|
878
|
+
if (value === void 0 || value === null) return void 0;
|
|
879
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
880
|
+
const body = value;
|
|
881
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
882
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
883
|
+
const auth = body.auth;
|
|
884
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
885
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
886
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
887
|
+
}
|
|
888
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
889
|
+
const config = {
|
|
890
|
+
host,
|
|
891
|
+
port: Number(body.port) || 22,
|
|
892
|
+
user,
|
|
893
|
+
auth,
|
|
894
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
895
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
896
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0
|
|
897
|
+
};
|
|
898
|
+
return config;
|
|
899
|
+
}
|
|
900
|
+
function keyType(blob) {
|
|
901
|
+
const len = blob.readUInt32BE(0);
|
|
902
|
+
return blob.subarray(4, 4 + len).toString("ascii");
|
|
903
|
+
}
|
|
904
|
+
function hostPatterns(host, port) {
|
|
905
|
+
const h = host.toLowerCase();
|
|
906
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
907
|
+
}
|
|
908
|
+
async function readKnownHosts(file) {
|
|
909
|
+
try {
|
|
910
|
+
return (await readFile2(file, "utf8")).split(/\r?\n/);
|
|
911
|
+
} catch {
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async function lookupHostKey(host, port, blob) {
|
|
916
|
+
const patterns = hostPatterns(host, port);
|
|
917
|
+
const type = keyType(blob);
|
|
918
|
+
const encoded = blob.toString("base64");
|
|
919
|
+
let sawType = false;
|
|
920
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
921
|
+
for (const raw of lines) {
|
|
922
|
+
const line = raw.trim();
|
|
923
|
+
if (!line || line.startsWith("#") || line.startsWith("@") || line.startsWith("|")) continue;
|
|
924
|
+
const [hosts, lineType, lineKey] = line.split(/\s+/);
|
|
925
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
926
|
+
if (!hosts.toLowerCase().split(",").some((h) => patterns.includes(h))) continue;
|
|
927
|
+
if (lineType !== type) continue;
|
|
928
|
+
if (lineKey === encoded) return "match";
|
|
929
|
+
sawType = true;
|
|
930
|
+
}
|
|
931
|
+
return sawType ? "mismatch" : "unknown";
|
|
932
|
+
}
|
|
933
|
+
async function rememberHostKey(host, port, blob) {
|
|
934
|
+
await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
|
|
935
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
936
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
937
|
+
`, { mode: 384 });
|
|
938
|
+
}
|
|
939
|
+
function fingerprint(blob) {
|
|
940
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
941
|
+
}
|
|
942
|
+
async function resolveConfig(ssh) {
|
|
943
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
944
|
+
if (cfg.proxyJump) {
|
|
945
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
946
|
+
}
|
|
947
|
+
const resolved = {
|
|
948
|
+
...ssh,
|
|
949
|
+
host: cfg.hostName ?? ssh.host,
|
|
950
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
951
|
+
user: ssh.user || cfg.user || ""
|
|
952
|
+
};
|
|
953
|
+
if (!resolved.user) {
|
|
954
|
+
throw new TunnelError(
|
|
955
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
956
|
+
400
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
960
|
+
if (SSH_CONFIG_DISABLED) {
|
|
961
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
962
|
+
}
|
|
963
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
964
|
+
for (const file of candidates) {
|
|
965
|
+
try {
|
|
966
|
+
resolved.privateKey = await readFile2(file, "utf8");
|
|
967
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
968
|
+
break;
|
|
969
|
+
} catch {
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (!resolved.privateKey) {
|
|
973
|
+
throw new TunnelError(
|
|
974
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.join(", ")})`,
|
|
975
|
+
400
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (resolved.auth === "agent" && cfg.identityAgent && !process.env.SSH_AUTH_SOCK) {
|
|
980
|
+
process.env.SSH_AUTH_SOCK = cfg.identityAgent;
|
|
981
|
+
}
|
|
982
|
+
return resolved;
|
|
983
|
+
}
|
|
984
|
+
function toConnectConfig(ssh) {
|
|
985
|
+
const base = {
|
|
986
|
+
host: ssh.host,
|
|
987
|
+
port: ssh.port,
|
|
988
|
+
username: ssh.user,
|
|
989
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
990
|
+
keepaliveInterval: 3e4
|
|
991
|
+
};
|
|
992
|
+
switch (ssh.auth) {
|
|
993
|
+
case "password":
|
|
994
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
995
|
+
case "key":
|
|
996
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
997
|
+
case "agent": {
|
|
998
|
+
const agent = process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "pageant" : void 0);
|
|
999
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
1000
|
+
return { ...base, agent };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function describeSshError(err, ssh) {
|
|
1005
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
1006
|
+
if (err instanceof TunnelError) return err;
|
|
1007
|
+
switch (err.level) {
|
|
1008
|
+
case "client-authentication":
|
|
1009
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
1010
|
+
case "client-timeout":
|
|
1011
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
1012
|
+
default:
|
|
1013
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async function openSshTunnel(requested, target) {
|
|
1017
|
+
const ssh = await resolveConfig(requested);
|
|
1018
|
+
const client = new Client();
|
|
1019
|
+
const connectConfig = toConnectConfig(ssh);
|
|
1020
|
+
let verifyError;
|
|
1021
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
1022
|
+
void lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
1023
|
+
if (result === "mismatch") {
|
|
1024
|
+
verifyError = new TunnelError(
|
|
1025
|
+
`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.`,
|
|
1026
|
+
400
|
|
1027
|
+
);
|
|
1028
|
+
verified(false);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (result === "unknown") {
|
|
1032
|
+
console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
|
|
1033
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
1034
|
+
}
|
|
1035
|
+
verified(true);
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
1038
|
+
await new Promise((resolve2, reject) => {
|
|
1039
|
+
client.once("ready", resolve2);
|
|
1040
|
+
client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1041
|
+
try {
|
|
1042
|
+
client.connect(connectConfig);
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
reject(describeSshError(err, ssh));
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1048
|
+
let forwardError;
|
|
1049
|
+
const server = createServer((socket) => {
|
|
1050
|
+
sockets.add(socket);
|
|
1051
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1052
|
+
client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
1053
|
+
if (err) {
|
|
1054
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
1055
|
+
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?";
|
|
1056
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
1057
|
+
console.error(`[ssh] ${forwardError}`);
|
|
1058
|
+
socket.destroy();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
socket.pipe(stream).pipe(socket);
|
|
1062
|
+
stream.once("close", () => socket.destroy());
|
|
1063
|
+
socket.once("close", () => stream.destroy());
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
await new Promise((resolve2, reject) => {
|
|
1067
|
+
server.once("error", reject);
|
|
1068
|
+
server.listen(0, "127.0.0.1", () => resolve2());
|
|
1069
|
+
}).catch((err) => {
|
|
1070
|
+
client.end();
|
|
1071
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
1072
|
+
});
|
|
1073
|
+
const { port } = server.address();
|
|
1074
|
+
let closed = false;
|
|
1075
|
+
const close = () => new Promise((resolve2) => {
|
|
1076
|
+
if (closed) return resolve2();
|
|
1077
|
+
closed = true;
|
|
1078
|
+
for (const s of sockets) s.destroy();
|
|
1079
|
+
server.close(() => {
|
|
1080
|
+
client.end();
|
|
1081
|
+
resolve2();
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
client.once("close", () => void close());
|
|
1085
|
+
return {
|
|
1086
|
+
host: "127.0.0.1",
|
|
1087
|
+
port,
|
|
1088
|
+
get forwardError() {
|
|
1089
|
+
return forwardError;
|
|
1090
|
+
},
|
|
1091
|
+
close
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
469
1095
|
// src/drivers/index.ts
|
|
470
1096
|
var factories = {
|
|
471
1097
|
mysql: mysqlDriverFactory,
|
|
472
1098
|
postgres: postgresDriverFactory,
|
|
473
|
-
sqlite: sqliteDriverFactory
|
|
1099
|
+
sqlite: sqliteDriverFactory,
|
|
1100
|
+
mssql: mssqlDriverFactory,
|
|
1101
|
+
oracle: oracleDriverFactory
|
|
474
1102
|
};
|
|
475
1103
|
function isDriverType(value) {
|
|
476
1104
|
return typeof value === "string" && value in factories;
|
|
@@ -480,9 +1108,53 @@ function parseDriverConfig(body) {
|
|
|
480
1108
|
if (!isDriverType(driver)) {
|
|
481
1109
|
throw new Error(`Unsupported driver: ${String(driver)}`);
|
|
482
1110
|
}
|
|
483
|
-
|
|
1111
|
+
const config = factories[driver].parseConfig(body);
|
|
1112
|
+
if (config.driver === "sqlite") return config;
|
|
1113
|
+
const ssh = parseSshConfig(body.ssh);
|
|
1114
|
+
return ssh ? { ...config, ssh } : config;
|
|
1115
|
+
}
|
|
1116
|
+
async function withTunnel(config, run) {
|
|
1117
|
+
if (config.driver === "sqlite" || !config.ssh) return run(config, void 0);
|
|
1118
|
+
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
1119
|
+
try {
|
|
1120
|
+
return await run({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
await tunnel.close();
|
|
1123
|
+
throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function tunneled(driver, tunnel, label) {
|
|
1127
|
+
return {
|
|
1128
|
+
type: driver.type,
|
|
1129
|
+
label,
|
|
1130
|
+
query: (sql2, database) => driver.query(sql2, database),
|
|
1131
|
+
async close() {
|
|
1132
|
+
try {
|
|
1133
|
+
await driver.close();
|
|
1134
|
+
} finally {
|
|
1135
|
+
await tunnel.close();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
484
1139
|
}
|
|
485
1140
|
function openDriver(config) {
|
|
1141
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
1142
|
+
const driver = await openDirect(direct);
|
|
1143
|
+
if (!tunnel || config.driver === "sqlite" || !config.ssh) return driver;
|
|
1144
|
+
const label = `${config.user}@${config.host}:${config.port} via ssh ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
|
|
1145
|
+
return tunneled(driver, tunnel, label);
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
function testDriver(config) {
|
|
1149
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
1150
|
+
try {
|
|
1151
|
+
await testDirect(direct);
|
|
1152
|
+
} finally {
|
|
1153
|
+
await tunnel?.close();
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function openDirect(config) {
|
|
486
1158
|
switch (config.driver) {
|
|
487
1159
|
case "mysql":
|
|
488
1160
|
return factories.mysql.open(config);
|
|
@@ -490,9 +1162,13 @@ function openDriver(config) {
|
|
|
490
1162
|
return factories.postgres.open(config);
|
|
491
1163
|
case "sqlite":
|
|
492
1164
|
return factories.sqlite.open(config);
|
|
1165
|
+
case "mssql":
|
|
1166
|
+
return factories.mssql.open(config);
|
|
1167
|
+
case "oracle":
|
|
1168
|
+
return factories.oracle.open(config);
|
|
493
1169
|
}
|
|
494
1170
|
}
|
|
495
|
-
function
|
|
1171
|
+
function testDirect(config) {
|
|
496
1172
|
switch (config.driver) {
|
|
497
1173
|
case "mysql":
|
|
498
1174
|
return factories.mysql.test(config);
|
|
@@ -500,6 +1176,10 @@ function testDriver(config) {
|
|
|
500
1176
|
return factories.postgres.test(config);
|
|
501
1177
|
case "sqlite":
|
|
502
1178
|
return factories.sqlite.test(config);
|
|
1179
|
+
case "mssql":
|
|
1180
|
+
return factories.mssql.test(config);
|
|
1181
|
+
case "oracle":
|
|
1182
|
+
return factories.oracle.test(config);
|
|
503
1183
|
}
|
|
504
1184
|
}
|
|
505
1185
|
|
|
@@ -540,13 +1220,13 @@ var ConnectionManager = class {
|
|
|
540
1220
|
* Execute a SQL query on an existing connection.
|
|
541
1221
|
* Optionally switch database before executing (drivers that support it).
|
|
542
1222
|
*/
|
|
543
|
-
async query(connectionId,
|
|
1223
|
+
async query(connectionId, sql2, database) {
|
|
544
1224
|
const driver = this.connections.get(connectionId);
|
|
545
1225
|
if (!driver) {
|
|
546
1226
|
throw new Error(`Connection not found: ${connectionId}`);
|
|
547
1227
|
}
|
|
548
1228
|
this.lastUsed.set(connectionId, Date.now());
|
|
549
|
-
return driver.query(
|
|
1229
|
+
return driver.query(sql2, database);
|
|
550
1230
|
}
|
|
551
1231
|
/**
|
|
552
1232
|
* Close and remove a connection.
|
|
@@ -594,6 +1274,10 @@ var ConnectionManager = class {
|
|
|
594
1274
|
};
|
|
595
1275
|
|
|
596
1276
|
// src/server.ts
|
|
1277
|
+
function errorStatus(err) {
|
|
1278
|
+
const status = err?.status;
|
|
1279
|
+
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
1280
|
+
}
|
|
597
1281
|
function fail(res, status, message) {
|
|
598
1282
|
res.status(status).json({ success: false, message });
|
|
599
1283
|
}
|
|
@@ -679,13 +1363,13 @@ function createAgent(options) {
|
|
|
679
1363
|
} catch (err) {
|
|
680
1364
|
const message = errorMessage(err);
|
|
681
1365
|
console.error("[connect] Error:", message);
|
|
682
|
-
fail(res,
|
|
1366
|
+
fail(res, errorStatus(err), message);
|
|
683
1367
|
}
|
|
684
1368
|
});
|
|
685
1369
|
app.post("/query", async (req, res) => {
|
|
686
1370
|
try {
|
|
687
|
-
const { connectionId, sql, database } = req.body;
|
|
688
|
-
if (typeof connectionId !== "string" || typeof
|
|
1371
|
+
const { connectionId, sql: sql2, database } = req.body;
|
|
1372
|
+
if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
|
|
689
1373
|
fail(res, 400, "connectionId and sql are required");
|
|
690
1374
|
return;
|
|
691
1375
|
}
|
|
@@ -694,7 +1378,7 @@ function createAgent(options) {
|
|
|
694
1378
|
return;
|
|
695
1379
|
}
|
|
696
1380
|
const start = Date.now();
|
|
697
|
-
const result = await manager.query(connectionId,
|
|
1381
|
+
const result = await manager.query(connectionId, sql2, database);
|
|
698
1382
|
const execTime = Date.now() - start;
|
|
699
1383
|
res.json({
|
|
700
1384
|
success: true,
|
|
@@ -737,7 +1421,7 @@ function createAgent(options) {
|
|
|
737
1421
|
} catch (err) {
|
|
738
1422
|
const message = errorMessage(err);
|
|
739
1423
|
console.error("[test-connection] Error:", message);
|
|
740
|
-
fail(res,
|
|
1424
|
+
fail(res, errorStatus(err), message);
|
|
741
1425
|
}
|
|
742
1426
|
});
|
|
743
1427
|
return { app, manager };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planche",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite/SQL Server/Oracle databases from your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://planche.dev",
|
|
7
7
|
"repository": {
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
"postgres",
|
|
29
29
|
"postgresql",
|
|
30
30
|
"sqlite",
|
|
31
|
+
"mssql",
|
|
32
|
+
"sqlserver",
|
|
33
|
+
"oracle",
|
|
31
34
|
"sql",
|
|
32
35
|
"client",
|
|
33
36
|
"gui"
|
|
@@ -42,14 +45,20 @@
|
|
|
42
45
|
"dependencies": {
|
|
43
46
|
"better-sqlite3": "^12.11.1",
|
|
44
47
|
"express": "^5.1.0",
|
|
48
|
+
"mssql": "^12.7.0",
|
|
45
49
|
"mysql2": "^3.14.1",
|
|
46
|
-
"
|
|
50
|
+
"oracledb": "^7.0.1",
|
|
51
|
+
"pg": "^8.23.0",
|
|
52
|
+
"ssh2": "^1.17.0"
|
|
47
53
|
},
|
|
48
54
|
"devDependencies": {
|
|
49
55
|
"@types/better-sqlite3": "^9.6.0",
|
|
50
56
|
"@types/express": "^5.0.0",
|
|
57
|
+
"@types/mssql": "^12.3.0",
|
|
51
58
|
"@types/node": "^24.0.0",
|
|
59
|
+
"@types/oracledb": "^7.0.2",
|
|
52
60
|
"@types/pg": "^8.23.1",
|
|
61
|
+
"@types/ssh2": "^1.15.5",
|
|
53
62
|
"tsup": "^8.5.0",
|
|
54
63
|
"tsx": "^4.19.0",
|
|
55
64
|
"typescript": "^5.9.0"
|