planche 0.5.0 → 0.5.2

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 +1 -0
  2. package/dist/index.js +148 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,6 +40,7 @@ npx planche [options]
40
40
  -p, --port <n> Port to listen on (default: 8888)
41
41
  --addr <host> Address to bind (default: 127.0.0.1)
42
42
  --token <str> Auth token (default: random on each start)
43
+ --print-token Print the token (hidden by default; the pairing link is enough)
43
44
  --no-open Print the pairing link without opening a browser
44
45
  --verbose Log every request
45
46
  -h, --help Show help
package/dist/index.js CHANGED
@@ -120,7 +120,11 @@ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
120
120
  result.user ??= value;
121
121
  break;
122
122
  case "identityfile":
123
- for (const f of d.args) result.identityFiles.push(expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host)));
123
+ for (const f of d.args) {
124
+ const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
125
+ if (expanded.split(/[\\/]/).includes("..")) continue;
126
+ result.identityFiles.push(expanded);
127
+ }
124
128
  break;
125
129
  case "identityagent":
126
130
  result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
@@ -164,14 +168,17 @@ function parseSshConfig(value) {
164
168
  const user = typeof body.user === "string" ? body.user.trim() : "";
165
169
  const auth = body.auth;
166
170
  if (!host) throw new TunnelError("ssh.host is required", 400);
167
- if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
171
+ if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
172
+ if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
173
+ const port = Math.trunc(Number(body.port)) || 22;
174
+ if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
168
175
  if (auth !== "password" && auth !== "key" && auth !== "agent") {
169
176
  throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
170
177
  }
171
178
  const str = (v) => typeof v === "string" && v ? v : void 0;
172
179
  const config = {
173
180
  host,
174
- port: Number(body.port) || 22,
181
+ port,
175
182
  user,
176
183
  auth,
177
184
  password: auth === "password" ? str(body.password) ?? "" : void 0,
@@ -185,7 +192,9 @@ function keyType(blob) {
185
192
  if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
186
193
  const len = blob.readUInt32BE(0);
187
194
  if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
188
- return blob.subarray(4, 4 + len).toString("ascii");
195
+ const type = blob.subarray(4, 4 + len).toString("ascii");
196
+ if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
197
+ return type;
189
198
  }
190
199
  function knownHostsEntryMatches(field, patterns) {
191
200
  if (field.startsWith("|1|")) {
@@ -216,19 +225,25 @@ async function lookupHostKey(host, port, blob) {
216
225
  const patterns = hostPatterns(host, port);
217
226
  const type = keyType(blob);
218
227
  const encoded = blob.toString("base64");
219
- let sawType = false;
228
+ let sawHost = false;
220
229
  const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
221
230
  for (const raw of lines) {
222
231
  const line = raw.trim();
223
- if (!line || line.startsWith("#") || line.startsWith("@")) continue;
224
- const [hosts, lineType, lineKey] = line.split(/\s+/);
232
+ if (!line || line.startsWith("#")) continue;
233
+ const fields = line.split(/\s+/);
234
+ const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
235
+ const [hosts, lineType, lineKey] = fields;
225
236
  if (!hosts || !lineType || !lineKey) continue;
226
237
  if (!knownHostsEntryMatches(hosts, patterns)) continue;
227
- if (lineType !== type) continue;
228
- if (lineKey === encoded) return "match";
229
- sawType = true;
238
+ if (marker === "@revoked") {
239
+ if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
240
+ continue;
241
+ }
242
+ if (marker === "@cert-authority") continue;
243
+ sawHost = true;
244
+ if (lineType === type && lineKey === encoded) return "match";
230
245
  }
231
- return sawType ? "mismatch" : "unknown";
246
+ return sawHost ? "mismatch" : "unknown";
232
247
  }
233
248
  async function rememberHostKey(host, port, blob) {
234
249
  await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
@@ -244,6 +259,9 @@ async function resolveConfig(ssh) {
244
259
  if (cfg.proxyJump) {
245
260
  throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
246
261
  }
262
+ if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
263
+ throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
264
+ }
247
265
  const resolved = {
248
266
  ...ssh,
249
267
  host: cfg.hostName ?? ssh.host,
@@ -350,11 +368,13 @@ async function openSshTunnel(requested, target) {
350
368
  reject(describeSshError(err, ssh));
351
369
  }
352
370
  });
371
+ client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
353
372
  const sockets = /* @__PURE__ */ new Set();
354
373
  let forwardError;
355
374
  const server = createServer((socket) => {
356
375
  sockets.add(socket);
357
376
  socket.once("close", () => sockets.delete(socket));
377
+ socket.on("error", () => socket.destroy());
358
378
  client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
359
379
  if (err) {
360
380
  const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
@@ -364,6 +384,7 @@ async function openSshTunnel(requested, target) {
364
384
  socket.destroy();
365
385
  return;
366
386
  }
387
+ stream.on("error", () => socket.destroy());
367
388
  socket.pipe(stream).pipe(socket);
368
389
  stream.once("close", () => socket.destroy());
369
390
  socket.once("close", () => stream.destroy());
@@ -444,7 +465,8 @@ function toConnectionOptions(config) {
444
465
  password: config.password,
445
466
  database: config.database || void 0,
446
467
  dateStrings: true,
447
- charset: config.charset || "utf8mb4"
468
+ charset: config.charset || "utf8mb4",
469
+ ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0
448
470
  };
449
471
  }
450
472
  var USE_STATEMENT = /^\s*USE\s/i;
@@ -526,7 +548,9 @@ var mysqlDriverFactory = {
526
548
  user,
527
549
  password: typeof body.password === "string" ? body.password : "",
528
550
  database: typeof body.database === "string" && body.database ? body.database : void 0,
529
- charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
551
+ charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
552
+ ssl: body.ssl === true,
553
+ sslVerify: body.sslVerify === true
530
554
  };
531
555
  }
532
556
  };
@@ -743,7 +767,6 @@ function inferTypeFromValue(value) {
743
767
  var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
744
768
  var DEFAULT_ROOT = homedir3();
745
769
  var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
746
- var ALLOW_URI = process.env.PLANCHE_SQLITE_URI === "1";
747
770
  var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
748
771
  var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
749
772
  function expandHome2(path) {
@@ -768,10 +791,7 @@ function isInside(path, root) {
768
791
  }
769
792
  function resolveFile(file, forCreate = false) {
770
793
  if (file === ":memory:") return file;
771
- if (file.startsWith("file:")) {
772
- if (!ALLOW_URI || SQLITE_DIR) throw new Error("SQLite URI filenames are disabled (set PLANCHE_SQLITE_URI=1 on the agent to allow them)");
773
- return file;
774
- }
794
+ if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
775
795
  const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
776
796
  if (SQLITE_DIR) {
777
797
  if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
@@ -780,10 +800,10 @@ function resolveFile(file, forCreate = false) {
780
800
  if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
781
801
  const parts = relative(root, resolved).split(sep);
782
802
  if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
783
- if (DENIED_UNDER_HOME.includes(parts[0])) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
803
+ if (DENIED_UNDER_HOME.some((d) => d.toLowerCase() === parts[0].toLowerCase())) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
784
804
  }
785
805
  if (forCreate && !DB_EXTENSIONS.test(resolved)) {
786
- throw new Error("A new SQLite file must end with .db, .sqlite or .sqlite3");
806
+ throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
787
807
  }
788
808
  return resolved;
789
809
  }
@@ -798,16 +818,63 @@ function openDatabase(config) {
798
818
  fileMustExist: !isMemory && !config.createIfMissing
799
819
  });
800
820
  db.pragma("trusted_schema = OFF");
821
+ if (config.readonly) db.pragma("query_only = ON");
801
822
  return db;
802
823
  }
803
- var ATTACH = /\bATTACH\b/i;
804
824
  function stripLiteralsAndComments(sql2) {
805
- return sql2.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""').replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
825
+ let out = "";
826
+ let i = 0;
827
+ while (i < sql2.length) {
828
+ const c = sql2[i];
829
+ if (c === "'" || c === '"' || c === "`") {
830
+ let j = i + 1;
831
+ while (j < sql2.length) {
832
+ if (sql2[j] === c) {
833
+ if (sql2[j + 1] === c) j += 2;
834
+ else {
835
+ j++;
836
+ break;
837
+ }
838
+ } else j++;
839
+ }
840
+ out += c + c;
841
+ i = j;
842
+ continue;
843
+ }
844
+ if (c === "[") {
845
+ const j = sql2.indexOf("]", i);
846
+ i = j < 0 ? sql2.length : j + 1;
847
+ out += "[]";
848
+ continue;
849
+ }
850
+ if (c === "-" && sql2[i + 1] === "-") {
851
+ const j = sql2.indexOf("\n", i);
852
+ i = j < 0 ? sql2.length : j;
853
+ out += " ";
854
+ continue;
855
+ }
856
+ if (c === "/" && sql2[i + 1] === "*") {
857
+ const j = sql2.indexOf("*/", i + 2);
858
+ i = j < 0 ? sql2.length : j + 2;
859
+ out += " ";
860
+ continue;
861
+ }
862
+ out += c;
863
+ i++;
864
+ }
865
+ return out;
806
866
  }
807
- function assertNoAttach(sql2) {
808
- if (!ALLOW_ATTACH && ATTACH.test(stripLiteralsAndComments(sql2))) {
867
+ function assertNoFileStatements(sql2) {
868
+ const text = stripLiteralsAndComments(sql2);
869
+ if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
809
870
  throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
810
871
  }
872
+ if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
873
+ throw new Error("VACUUM INTO is not available through the agent");
874
+ }
875
+ if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
876
+ throw new Error("PRAGMA temp_store_directory is not available through the agent");
877
+ }
811
878
  }
812
879
  var SqliteDriver = class {
813
880
  constructor(db, config) {
@@ -818,7 +885,7 @@ var SqliteDriver = class {
818
885
  type = "sqlite";
819
886
  label;
820
887
  async query(sql2) {
821
- assertNoAttach(sql2);
888
+ assertNoFileStatements(sql2);
822
889
  let statement;
823
890
  try {
824
891
  statement = this.db.prepare(sql2);
@@ -912,7 +979,7 @@ function toPoolConfig(config) {
912
979
  pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
913
980
  options: {
914
981
  encrypt: config.ssl === true,
915
- trustServerCertificate: true,
982
+ trustServerCertificate: config.sslVerify !== true,
916
983
  // Keep date/time values as sent by the server rather than shifting to UTC.
917
984
  useUTC: false,
918
985
  enableArithAbort: true
@@ -1032,7 +1099,8 @@ var mssqlDriverFactory = {
1032
1099
  user,
1033
1100
  password: typeof body.password === "string" ? body.password : "",
1034
1101
  database: typeof body.database === "string" && body.database ? body.database : "master",
1035
- ssl: body.ssl === true
1102
+ ssl: body.ssl === true,
1103
+ sslVerify: body.sslVerify === true
1036
1104
  };
1037
1105
  }
1038
1106
  };
@@ -1173,13 +1241,16 @@ var oracleDriverFactory = {
1173
1241
  if (!host || !user) {
1174
1242
  throw new Error("host and user are required");
1175
1243
  }
1244
+ if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
1245
+ const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
1246
+ if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
1176
1247
  return {
1177
1248
  driver: "oracle",
1178
1249
  host,
1179
1250
  port: Number(body.port) || 1521,
1180
1251
  user,
1181
1252
  password: typeof body.password === "string" ? body.password : "",
1182
- serviceName: typeof body.serviceName === "string" && body.serviceName ? body.serviceName : "FREEPDB1"
1253
+ serviceName
1183
1254
  };
1184
1255
  }
1185
1256
  };
@@ -1241,7 +1312,7 @@ var D1Driver = class {
1241
1312
  async raw(sql2) {
1242
1313
  let res;
1243
1314
  try {
1244
- res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }) });
1315
+ res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
1245
1316
  } catch (err) {
1246
1317
  throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
1247
1318
  }
@@ -1451,6 +1522,7 @@ function testDirect(config) {
1451
1522
 
1452
1523
  // src/tunneling.ts
1453
1524
  var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
1525
+ var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
1454
1526
  var ConnectionManager = class {
1455
1527
  connections = /* @__PURE__ */ new Map();
1456
1528
  lastUsed = /* @__PURE__ */ new Map();
@@ -1472,6 +1544,9 @@ var ConnectionManager = class {
1472
1544
  * Open a new connection and return a unique connection ID.
1473
1545
  */
1474
1546
  async connect(config) {
1547
+ if (this.connections.size >= MAX_CONNECTIONS) {
1548
+ throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
1549
+ }
1475
1550
  const driver = await openDriver(config);
1476
1551
  const connectionId = randomUUID();
1477
1552
  this.connections.set(connectionId, driver);
@@ -1572,20 +1647,24 @@ var AuthThrottle = class {
1572
1647
  max;
1573
1648
  windowMs;
1574
1649
  lockMs;
1575
- failures = [];
1576
- lockedUntil = 0;
1577
- locked() {
1578
- return Date.now() < this.lockedUntil;
1650
+ failures = /* @__PURE__ */ new Map();
1651
+ lockedUntil = /* @__PURE__ */ new Map();
1652
+ /** Keyed by peer address so one local process cannot lock the browser out. */
1653
+ locked(peer) {
1654
+ return Date.now() < (this.lockedUntil.get(peer) ?? 0);
1579
1655
  }
1580
- fail() {
1656
+ fail(peer) {
1581
1657
  const now = Date.now();
1582
- this.failures = this.failures.filter((t) => now - t < this.windowMs);
1583
- this.failures.push(now);
1584
- if (this.failures.length >= this.max) {
1585
- this.lockedUntil = now + this.lockMs;
1586
- this.failures = [];
1587
- console.warn(`[agent] Too many bad tokens; refusing authentication for ${this.lockMs / 1e3}s`);
1658
+ const recent = (this.failures.get(peer) ?? []).filter((t) => now - t < this.windowMs);
1659
+ recent.push(now);
1660
+ if (recent.length >= this.max) {
1661
+ this.lockedUntil.set(peer, now + this.lockMs);
1662
+ this.failures.delete(peer);
1663
+ console.warn(`[agent] Too many bad tokens from ${peer}; refusing its authentication for ${this.lockMs / 1e3}s`);
1664
+ } else {
1665
+ this.failures.set(peer, recent);
1588
1666
  }
1667
+ if (this.failures.size > 1e3) this.failures.clear();
1589
1668
  }
1590
1669
  };
1591
1670
  function accessControl(allowed) {
@@ -1627,12 +1706,14 @@ function createAgent(options) {
1627
1706
  app.set("env", "production");
1628
1707
  app.disable("x-powered-by");
1629
1708
  app.disable("etag");
1630
- const authorized = (header) => {
1631
- if (throttle.locked()) return false;
1632
- const given = bearerOf(header);
1709
+ const peerOf = (req) => req.socket.remoteAddress ?? "unknown";
1710
+ const authorized = (req) => {
1711
+ const peer = peerOf(req);
1712
+ if (throttle.locked(peer)) return false;
1713
+ const given = bearerOf(req.headers.authorization);
1633
1714
  if (given === null) return false;
1634
1715
  const ok = secretEquals(given, options.authToken);
1635
- if (!ok) throttle.fail();
1716
+ if (!ok) throttle.fail(peer);
1636
1717
  return ok;
1637
1718
  };
1638
1719
  if (options.logRequests) {
@@ -1665,22 +1746,22 @@ function createAgent(options) {
1665
1746
  uptime: process.uptime()
1666
1747
  };
1667
1748
  if (req.headers.authorization !== void 0) {
1668
- if (throttle.locked()) {
1749
+ if (throttle.locked(peerOf(req))) {
1669
1750
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
1670
1751
  return;
1671
1752
  }
1672
- body.authorized = authorized(req.headers.authorization);
1753
+ body.authorized = authorized(req);
1673
1754
  }
1674
1755
  res.json(body);
1675
1756
  });
1676
1757
  app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
1677
- if (throttle.locked()) {
1758
+ if (throttle.locked(peerOf(req))) {
1678
1759
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
1679
1760
  return;
1680
1761
  }
1681
1762
  const code = typeof req.body?.code === "string" ? req.body.code : "";
1682
1763
  if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
1683
- throttle.fail();
1764
+ throttle.fail(peerOf(req));
1684
1765
  fail(res, 401, "Pairing code is invalid or has expired; restart the agent for a new link");
1685
1766
  return;
1686
1767
  }
@@ -1689,11 +1770,11 @@ function createAgent(options) {
1689
1770
  res.json(body);
1690
1771
  });
1691
1772
  app.use((req, res, next) => {
1692
- if (throttle.locked()) {
1773
+ if (throttle.locked(peerOf(req))) {
1693
1774
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
1694
1775
  return;
1695
1776
  }
1696
- if (!authorized(req.headers.authorization)) {
1777
+ if (!authorized(req)) {
1697
1778
  fail(res, 401, "Unauthorized: invalid or missing auth token");
1698
1779
  return;
1699
1780
  }
@@ -1713,7 +1794,7 @@ function createAgent(options) {
1713
1794
  });
1714
1795
  app.post("/query", async (req, res) => {
1715
1796
  try {
1716
- const { connectionId, sql: sql2, database } = req.body;
1797
+ const { connectionId, sql: sql2, database } = req.body ?? {};
1717
1798
  if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
1718
1799
  fail(res, 400, "connectionId and sql are required");
1719
1800
  return;
@@ -1723,7 +1804,7 @@ function createAgent(options) {
1723
1804
  return;
1724
1805
  }
1725
1806
  const start = Date.now();
1726
- const result = await manager.query(connectionId, sql2, database);
1807
+ const result = await manager.query(connectionId, sql2, typeof database === "string" ? database : void 0);
1727
1808
  const execTime = Date.now() - start;
1728
1809
  res.json({
1729
1810
  success: true,
@@ -1742,7 +1823,7 @@ function createAgent(options) {
1742
1823
  });
1743
1824
  app.post("/disconnect", async (req, res) => {
1744
1825
  try {
1745
- const { connectionId } = req.body;
1826
+ const { connectionId } = req.body ?? {};
1746
1827
  if (typeof connectionId !== "string" || !connectionId) {
1747
1828
  fail(res, 400, "connectionId is required");
1748
1829
  return;
@@ -1798,6 +1879,7 @@ Options:
1798
1879
  -p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
1799
1880
  --addr <host> Address to bind (default: 127.0.0.1, env PLANCHE_ADDR)
1800
1881
  --token <str> Auth token (default: random, env PLANCHE_AUTH_TOKEN)
1882
+ --print-token Print the token (for manual setup); hidden by default
1801
1883
  --app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
1802
1884
  --origin <url> Extra browser origin allowed to use the agent (repeatable,
1803
1885
  env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
@@ -1825,7 +1907,9 @@ var MIN_TOKEN_LENGTH = 16;
1825
1907
  function openInBrowser(url) {
1826
1908
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1827
1909
  try {
1828
- spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
1910
+ const env = { ...process.env };
1911
+ delete env.PLANCHE_AUTH_TOKEN;
1912
+ spawn(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
1829
1913
  } catch {
1830
1914
  }
1831
1915
  }
@@ -1848,6 +1932,7 @@ function main() {
1848
1932
  open: { type: "boolean" },
1849
1933
  verbose: { type: "boolean" },
1850
1934
  "allow-remote": { type: "boolean" },
1935
+ "print-token": { type: "boolean" },
1851
1936
  version: { type: "boolean", short: "v" },
1852
1937
  help: { type: "boolean", short: "h" }
1853
1938
  },
@@ -1906,18 +1991,20 @@ function main() {
1906
1991
  const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
1907
1992
  const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
1908
1993
  const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
1909
- const agentUrl = `http://${addr}:${port}`;
1994
+ const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
1995
+ const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
1910
1996
  const { app, manager } = createAgent({
1911
1997
  authToken,
1912
1998
  allowedOrigins,
1913
1999
  bodyLimit,
1914
2000
  version: VERSION,
1915
2001
  logRequests: flags.verbose ?? false,
1916
- allowedHosts: loopback ? [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
2002
+ // Browsers omit a default port from `Host`, so the bare names are allowed too.
2003
+ allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
1917
2004
  pairingCode
1918
2005
  });
1919
2006
  const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
1920
- const server = app.listen(port, addr, () => {
2007
+ const server = app.listen(port, listenAddr, () => {
1921
2008
  const line = "-".repeat(72);
1922
2009
  console.log(line);
1923
2010
  console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
@@ -1926,7 +2013,8 @@ function main() {
1926
2013
 
1927
2014
  ${pairUrl}
1928
2015
  `);
1929
- console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token (for manual setup): ${authToken}`);
2016
+ if (flags["print-token"]) console.log(`Auth token: ${authToken}`);
2017
+ else console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : "Auth token: hidden \u2014 run with --print-token to show it for manual setup");
1930
2018
  console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
1931
2019
  if (!loopback) {
1932
2020
  console.error(`WARNING: bound to ${addr} without TLS \u2014 anyone who can reach this port and knows the token can use your databases, and anyone on the path can read the traffic.`);
@@ -1949,5 +2037,7 @@ function main() {
1949
2037
  };
1950
2038
  process.on("SIGINT", shutdown);
1951
2039
  process.on("SIGTERM", shutdown);
2040
+ process.on("uncaughtException", (err) => console.error("[agent] Uncaught exception:", err instanceof Error ? err.message : String(err)));
2041
+ process.on("unhandledRejection", (err) => console.error("[agent] Unhandled rejection:", err instanceof Error ? err.message : String(err)));
1952
2042
  }
1953
2043
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planche",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite/SQL Server/Oracle/Neon/Supabase/Cloudflare D1 databases from your machine.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://planche.dev",