planche 0.1.0 → 0.2.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 +23 -16
  2. package/dist/index.js +399 -4
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -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,35 @@ 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
+ - **SSH tunnel** — MySQL and PostgreSQL profiles can go through an SSH jump
94
+ host (password, private key, or the local `ssh-agent`). The agent opens the
95
+ tunnel with `ssh2` on a loopback port and points the driver at it. The host
96
+ may be a `Host` alias from `~/.ssh/config`: `HostName`, `Port`, `User` and
97
+ `IdentityFile` are applied like the `ssh` command does (`ProxyJump` is not
98
+ supported), and with no key given `~/.ssh/id_*` are tried. Unknown
99
+ host keys are trusted on first use and recorded in `~/.planche/known_hosts`
100
+ (entries in `~/.ssh/known_hosts` are honoured too); a changed key refuses the
101
+ connection until the stale entry is removed.
102
102
 
103
103
  ## Security
104
104
 
105
105
  - 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`.
106
+ - Browser requests are accepted only from the Planche app origin
107
+ (`https://planche.dev`). Any other `Origin` is refused with `403`, even
108
+ with a valid token.
109
109
  - The agent binds to `127.0.0.1` by default, so only the browser on the same
110
110
  machine can reach it. Binding to another address (`--addr 0.0.0.0`) exposes
111
111
  your databases to the network — only do that behind a tunnel or firewall you
112
112
  trust.
113
113
  - The agent never stores credentials. They are passed per connection by the
114
- app and live in memory only while the connection is open.
114
+ app (including SSH passwords and private key contents) and live in memory
115
+ only while the connection is open. The only thing written to disk is the
116
+ SSH host key list in `~/.planche/known_hosts`.
117
+ - For SSH tunnels the agent reads `~/.ssh/config` and, when the profile has no
118
+ key, the `IdentityFile` it names or `~/.ssh/id_*` — exactly what the `ssh`
119
+ command would do, and the key never leaves the agent. Set
120
+ `PLANCHE_SSH_CONFIG=none` if you would rather the agent only used what the
121
+ app sends.
115
122
 
116
123
  ## Troubleshooting
117
124
 
@@ -120,8 +127,8 @@ database connections open and forwards queries with the matching driver
120
127
  browser extensions that block `localhost` requests can also cause this.
121
128
  - **"Agent restarted, re-pair"** — the agent generated a new token. Open the
122
129
  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`.
130
+ - **`403 Forbidden`** — the request did not come from `https://planche.dev`.
131
+ Open the app from the pairing link the agent printed.
125
132
  - **Wrong Node version** — the SQLite driver ships prebuilt binaries for
126
133
  Node 22+. Check with `node -v`, or run with `npx -p node@22 planche`.
127
134
 
package/dist/index.js CHANGED
@@ -466,6 +466,347 @@ var sqliteDriverFactory = {
466
466
  }
467
467
  };
468
468
 
469
+ // src/ssh-tunnel.ts
470
+ import { createServer } from "net";
471
+ import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
472
+ import { createHash } from "crypto";
473
+ import { homedir as homedir2 } from "os";
474
+ import { join as join2 } from "path";
475
+ import { Client } from "ssh2";
476
+
477
+ // src/ssh-config.ts
478
+ import { readFile } from "fs/promises";
479
+ import { homedir } from "os";
480
+ import { dirname, isAbsolute as isAbsolute2, join } from "path";
481
+ var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
482
+ var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
483
+ var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
484
+ (f) => join(homedir(), ".ssh", f)
485
+ );
486
+ function expandHome(path) {
487
+ return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
488
+ }
489
+ function globToRegExp(pattern) {
490
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
491
+ return new RegExp(`^${escaped}$`, "i");
492
+ }
493
+ function hostPatternMatches(patterns, host) {
494
+ let matched = false;
495
+ for (const raw of patterns) {
496
+ const negated = raw.startsWith("!");
497
+ const pattern = negated ? raw.slice(1) : raw;
498
+ if (!globToRegExp(pattern).test(host)) continue;
499
+ if (negated) return false;
500
+ matched = true;
501
+ }
502
+ return matched;
503
+ }
504
+ function tokenize(line) {
505
+ const tokens = [];
506
+ const re = /"([^"]*)"|(\S+)/g;
507
+ let m;
508
+ while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
509
+ return tokens;
510
+ }
511
+ function parseLine(line) {
512
+ const trimmed = line.trim();
513
+ if (!trimmed || trimmed.startsWith("#")) return void 0;
514
+ const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
515
+ const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
516
+ return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
517
+ }
518
+ async function loadLines(file, seen) {
519
+ if (seen.has(file)) return [];
520
+ seen.add(file);
521
+ try {
522
+ return (await readFile(file, "utf8")).split(/\r?\n/);
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
527
+ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
528
+ const result = { identityFiles: [] };
529
+ if (!file) return result;
530
+ const seen = /* @__PURE__ */ new Set();
531
+ async function walk(path, active) {
532
+ let blockActive = active;
533
+ for (const line of await loadLines(path, seen)) {
534
+ const d = parseLine(line);
535
+ if (!d) continue;
536
+ switch (d.keyword) {
537
+ case "host":
538
+ blockActive = hostPatternMatches(d.args, host);
539
+ continue;
540
+ case "match":
541
+ blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
542
+ continue;
543
+ case "include":
544
+ for (const inc of d.args) {
545
+ const target = expandHome(inc);
546
+ await walk(isAbsolute2(target) ? target : join(dirname(file), target), blockActive);
547
+ }
548
+ continue;
549
+ }
550
+ if (!blockActive) continue;
551
+ const value = d.args[0];
552
+ if (value === void 0) continue;
553
+ switch (d.keyword) {
554
+ case "hostname":
555
+ result.hostName ??= value.replace(/%h/g, host);
556
+ break;
557
+ case "port":
558
+ result.port ??= Number(value) || void 0;
559
+ break;
560
+ case "user":
561
+ result.user ??= value;
562
+ break;
563
+ case "identityfile":
564
+ for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
565
+ break;
566
+ case "identityagent":
567
+ result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
568
+ break;
569
+ case "proxyjump":
570
+ result.proxyJump ??= value;
571
+ break;
572
+ }
573
+ }
574
+ }
575
+ await walk(file, true);
576
+ return result;
577
+ }
578
+
579
+ // src/ssh-tunnel.ts
580
+ var TunnelError = class extends Error {
581
+ constructor(message, status) {
582
+ super(message);
583
+ this.status = status;
584
+ this.name = "TunnelError";
585
+ }
586
+ status;
587
+ };
588
+ var CONNECT_TIMEOUT_MS = 2e4;
589
+ var PLANCHE_DIR = join2(homedir2(), ".planche");
590
+ var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
591
+ var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
592
+ function parseSshConfig(value) {
593
+ if (value === void 0 || value === null) return void 0;
594
+ if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
595
+ const body = value;
596
+ const host = typeof body.host === "string" ? body.host.trim() : "";
597
+ const user = typeof body.user === "string" ? body.user.trim() : "";
598
+ const auth = body.auth;
599
+ if (!host) throw new TunnelError("ssh.host is required", 400);
600
+ if (auth !== "password" && auth !== "key" && auth !== "agent") {
601
+ throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
602
+ }
603
+ const str = (v) => typeof v === "string" && v ? v : void 0;
604
+ const config = {
605
+ host,
606
+ port: Number(body.port) || 22,
607
+ user,
608
+ auth,
609
+ password: auth === "password" ? str(body.password) ?? "" : void 0,
610
+ privateKey: auth === "key" ? str(body.privateKey) : void 0,
611
+ passphrase: auth === "key" ? str(body.passphrase) : void 0
612
+ };
613
+ return config;
614
+ }
615
+ function keyType(blob) {
616
+ const len = blob.readUInt32BE(0);
617
+ return blob.subarray(4, 4 + len).toString("ascii");
618
+ }
619
+ function hostPatterns(host, port) {
620
+ const h = host.toLowerCase();
621
+ return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
622
+ }
623
+ async function readKnownHosts(file) {
624
+ try {
625
+ return (await readFile2(file, "utf8")).split(/\r?\n/);
626
+ } catch {
627
+ return [];
628
+ }
629
+ }
630
+ async function lookupHostKey(host, port, blob) {
631
+ const patterns = hostPatterns(host, port);
632
+ const type = keyType(blob);
633
+ const encoded = blob.toString("base64");
634
+ let sawType = false;
635
+ const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
636
+ for (const raw of lines) {
637
+ const line = raw.trim();
638
+ if (!line || line.startsWith("#") || line.startsWith("@") || line.startsWith("|")) continue;
639
+ const [hosts, lineType, lineKey] = line.split(/\s+/);
640
+ if (!hosts || !lineType || !lineKey) continue;
641
+ if (!hosts.toLowerCase().split(",").some((h) => patterns.includes(h))) continue;
642
+ if (lineType !== type) continue;
643
+ if (lineKey === encoded) return "match";
644
+ sawType = true;
645
+ }
646
+ return sawType ? "mismatch" : "unknown";
647
+ }
648
+ async function rememberHostKey(host, port, blob) {
649
+ await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
650
+ const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
651
+ await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
652
+ `, { mode: 384 });
653
+ }
654
+ function fingerprint(blob) {
655
+ return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
656
+ }
657
+ async function resolveConfig(ssh) {
658
+ const cfg = await resolveSshHost(ssh.host);
659
+ if (cfg.proxyJump) {
660
+ throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
661
+ }
662
+ const resolved = {
663
+ ...ssh,
664
+ host: cfg.hostName ?? ssh.host,
665
+ port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
666
+ user: ssh.user || cfg.user || ""
667
+ };
668
+ if (!resolved.user) {
669
+ throw new TunnelError(
670
+ SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
671
+ 400
672
+ );
673
+ }
674
+ if (resolved.auth === "key" && !resolved.privateKey) {
675
+ if (SSH_CONFIG_DISABLED) {
676
+ throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
677
+ }
678
+ const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
679
+ for (const file of candidates) {
680
+ try {
681
+ resolved.privateKey = await readFile2(file, "utf8");
682
+ console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
683
+ break;
684
+ } catch {
685
+ }
686
+ }
687
+ if (!resolved.privateKey) {
688
+ throw new TunnelError(
689
+ `No private key given and none readable for ${ssh.host} (tried ${candidates.join(", ")})`,
690
+ 400
691
+ );
692
+ }
693
+ }
694
+ if (resolved.auth === "agent" && cfg.identityAgent && !process.env.SSH_AUTH_SOCK) {
695
+ process.env.SSH_AUTH_SOCK = cfg.identityAgent;
696
+ }
697
+ return resolved;
698
+ }
699
+ function toConnectConfig(ssh) {
700
+ const base = {
701
+ host: ssh.host,
702
+ port: ssh.port,
703
+ username: ssh.user,
704
+ readyTimeout: CONNECT_TIMEOUT_MS,
705
+ keepaliveInterval: 3e4
706
+ };
707
+ switch (ssh.auth) {
708
+ case "password":
709
+ return { ...base, password: ssh.password ?? "", tryKeyboard: true };
710
+ case "key":
711
+ return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
712
+ case "agent": {
713
+ const agent = process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "pageant" : void 0);
714
+ if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
715
+ return { ...base, agent };
716
+ }
717
+ }
718
+ }
719
+ function describeSshError(err, ssh) {
720
+ const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
721
+ if (err instanceof TunnelError) return err;
722
+ switch (err.level) {
723
+ case "client-authentication":
724
+ return new TunnelError(`SSH authentication failed for ${target}`, 401);
725
+ case "client-timeout":
726
+ return new TunnelError(`SSH connection to ${target} timed out`, 400);
727
+ default:
728
+ return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
729
+ }
730
+ }
731
+ async function openSshTunnel(requested, target) {
732
+ const ssh = await resolveConfig(requested);
733
+ const client = new Client();
734
+ const connectConfig = toConnectConfig(ssh);
735
+ let verifyError;
736
+ connectConfig.hostVerifier = (key, verified) => {
737
+ void lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
738
+ if (result === "mismatch") {
739
+ verifyError = new TunnelError(
740
+ `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.`,
741
+ 400
742
+ );
743
+ verified(false);
744
+ return;
745
+ }
746
+ if (result === "unknown") {
747
+ console.log(`[ssh] Trusting new host key for ${ssh.host}:${ssh.port} (${fingerprint(key)}), saved to ${PLANCHE_KNOWN_HOSTS}`);
748
+ await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
749
+ }
750
+ verified(true);
751
+ });
752
+ };
753
+ await new Promise((resolve2, reject) => {
754
+ client.once("ready", resolve2);
755
+ client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
756
+ try {
757
+ client.connect(connectConfig);
758
+ } catch (err) {
759
+ reject(describeSshError(err, ssh));
760
+ }
761
+ });
762
+ const sockets = /* @__PURE__ */ new Set();
763
+ let forwardError;
764
+ const server = createServer((socket) => {
765
+ sockets.add(socket);
766
+ socket.once("close", () => sockets.delete(socket));
767
+ client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
768
+ if (err) {
769
+ const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
770
+ 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?";
771
+ forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
772
+ console.error(`[ssh] ${forwardError}`);
773
+ socket.destroy();
774
+ return;
775
+ }
776
+ socket.pipe(stream).pipe(socket);
777
+ stream.once("close", () => socket.destroy());
778
+ socket.once("close", () => stream.destroy());
779
+ });
780
+ });
781
+ await new Promise((resolve2, reject) => {
782
+ server.once("error", reject);
783
+ server.listen(0, "127.0.0.1", () => resolve2());
784
+ }).catch((err) => {
785
+ client.end();
786
+ throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
787
+ });
788
+ const { port } = server.address();
789
+ let closed = false;
790
+ const close = () => new Promise((resolve2) => {
791
+ if (closed) return resolve2();
792
+ closed = true;
793
+ for (const s of sockets) s.destroy();
794
+ server.close(() => {
795
+ client.end();
796
+ resolve2();
797
+ });
798
+ });
799
+ client.once("close", () => void close());
800
+ return {
801
+ host: "127.0.0.1",
802
+ port,
803
+ get forwardError() {
804
+ return forwardError;
805
+ },
806
+ close
807
+ };
808
+ }
809
+
469
810
  // src/drivers/index.ts
470
811
  var factories = {
471
812
  mysql: mysqlDriverFactory,
@@ -480,9 +821,53 @@ function parseDriverConfig(body) {
480
821
  if (!isDriverType(driver)) {
481
822
  throw new Error(`Unsupported driver: ${String(driver)}`);
482
823
  }
483
- return factories[driver].parseConfig(body);
824
+ const config = factories[driver].parseConfig(body);
825
+ if (config.driver === "sqlite") return config;
826
+ const ssh = parseSshConfig(body.ssh);
827
+ return ssh ? { ...config, ssh } : config;
828
+ }
829
+ async function withTunnel(config, run) {
830
+ if (config.driver === "sqlite" || !config.ssh) return run(config, void 0);
831
+ const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
832
+ try {
833
+ return await run({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
834
+ } catch (err) {
835
+ await tunnel.close();
836
+ throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
837
+ }
838
+ }
839
+ function tunneled(driver, tunnel, label) {
840
+ return {
841
+ type: driver.type,
842
+ label,
843
+ query: (sql, database) => driver.query(sql, database),
844
+ async close() {
845
+ try {
846
+ await driver.close();
847
+ } finally {
848
+ await tunnel.close();
849
+ }
850
+ }
851
+ };
484
852
  }
485
853
  function openDriver(config) {
854
+ return withTunnel(config, async (direct, tunnel) => {
855
+ const driver = await openDirect(direct);
856
+ if (!tunnel || config.driver === "sqlite" || !config.ssh) return driver;
857
+ const label = `${config.user}@${config.host}:${config.port} via ssh ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
858
+ return tunneled(driver, tunnel, label);
859
+ });
860
+ }
861
+ function testDriver(config) {
862
+ return withTunnel(config, async (direct, tunnel) => {
863
+ try {
864
+ await testDirect(direct);
865
+ } finally {
866
+ await tunnel?.close();
867
+ }
868
+ });
869
+ }
870
+ function openDirect(config) {
486
871
  switch (config.driver) {
487
872
  case "mysql":
488
873
  return factories.mysql.open(config);
@@ -492,7 +877,7 @@ function openDriver(config) {
492
877
  return factories.sqlite.open(config);
493
878
  }
494
879
  }
495
- function testDriver(config) {
880
+ function testDirect(config) {
496
881
  switch (config.driver) {
497
882
  case "mysql":
498
883
  return factories.mysql.test(config);
@@ -594,6 +979,10 @@ var ConnectionManager = class {
594
979
  };
595
980
 
596
981
  // src/server.ts
982
+ function errorStatus(err) {
983
+ const status = err?.status;
984
+ return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
985
+ }
597
986
  function fail(res, status, message) {
598
987
  res.status(status).json({ success: false, message });
599
988
  }
@@ -679,7 +1068,7 @@ function createAgent(options) {
679
1068
  } catch (err) {
680
1069
  const message = errorMessage(err);
681
1070
  console.error("[connect] Error:", message);
682
- fail(res, 400, message);
1071
+ fail(res, errorStatus(err), message);
683
1072
  }
684
1073
  });
685
1074
  app.post("/query", async (req, res) => {
@@ -737,7 +1126,7 @@ function createAgent(options) {
737
1126
  } catch (err) {
738
1127
  const message = errorMessage(err);
739
1128
  console.error("[test-connection] Error:", message);
740
- fail(res, 400, message);
1129
+ fail(res, errorStatus(err), message);
741
1130
  }
742
1131
  });
743
1132
  return { app, manager };
@@ -788,6 +1177,12 @@ function openInBrowser(url) {
788
1177
  }
789
1178
  }
790
1179
  function main() {
1180
+ const major = Number(process.versions.node.split(".")[0]);
1181
+ if (major < 22) {
1182
+ console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
1183
+ console.error("Try `nvm use 22` or `npx -p node@22 planche`.");
1184
+ process.exit(1);
1185
+ }
791
1186
  let values;
792
1187
  try {
793
1188
  values = parseArgs({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planche",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://planche.dev",
@@ -43,13 +43,15 @@
43
43
  "better-sqlite3": "^12.11.1",
44
44
  "express": "^5.1.0",
45
45
  "mysql2": "^3.14.1",
46
- "pg": "^8.23.0"
46
+ "pg": "^8.23.0",
47
+ "ssh2": "^1.17.0"
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/better-sqlite3": "^9.6.0",
50
51
  "@types/express": "^5.0.0",
51
52
  "@types/node": "^24.0.0",
52
53
  "@types/pg": "^8.23.1",
54
+ "@types/ssh2": "^1.15.5",
53
55
  "tsup": "^8.5.0",
54
56
  "tsx": "^4.19.0",
55
57
  "typescript": "^5.9.0"