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.
Files changed (3) hide show
  1. package/README.md +31 -17
  2. package/dist/index.js +703 -19
  3. 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 SQLite.
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 and local
107
- dev servers. Any other `Origin` is refused with `403`, even with a valid
108
- token. Add your own with `--origin`.
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 and live in memory only while the connection is open.
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 came from an origin that is not
124
- allow-listed. Pass `--origin https://your-app-host`.
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(sql, database) {
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(sql);
89
- if (USE_STATEMENT.test(sql)) this.currentDatabase = null;
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(sql, database) {
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: sql, rowMode: "array" });
278
- if (SEARCH_PATH_STATEMENT.test(sql)) this.currentSchema = null;
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(sql) {
388
+ async query(sql2) {
389
389
  let statement;
390
390
  try {
391
- statement = this.db.prepare(sql);
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(sql);
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
- return factories[driver].parseConfig(body);
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 testDriver(config) {
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, sql, database) {
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(sql, database);
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, 400, message);
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 sql !== "string" || !connectionId || !sql) {
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, sql, database);
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, 400, message);
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.1.1",
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
- "pg": "^8.23.0"
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"