planche 0.1.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 +130 -0
  2. package/dist/index.js +882 -0
  3. package/package.json +57 -0
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # planche
2
+
3
+ Local agent for [Planche](https://planche.dev), a browser-based SQL client for
4
+ MySQL, MariaDB, PostgreSQL and SQLite.
5
+
6
+ Browsers cannot open TCP connections to a database, so Planche runs this small
7
+ agent on your machine. The app in your browser talks to the agent over
8
+ `127.0.0.1`, and the agent talks to your databases. Credentials and query
9
+ results stay on your computer — nothing is sent to planche.dev.
10
+
11
+ ## Quick start
12
+
13
+ Requires Node.js 22 or newer.
14
+
15
+ ```bash
16
+ npx planche
17
+ ```
18
+
19
+ The agent starts on port 8888, prints a pairing link and opens it in your
20
+ default browser:
21
+
22
+ ```
23
+ https://planche.dev/#agent=http://127.0.0.1:8888&token=…
24
+ ```
25
+
26
+ Opening that link pairs the app with the agent. From then on you can add
27
+ connections in the app as usual; every connection is opened by the agent on
28
+ your machine.
29
+
30
+ The token is random on every start. If you restart the agent, the app shows
31
+ "agent restarted" and asks you to open the new link (or set a fixed token, see
32
+ below).
33
+
34
+ ## Usage
35
+
36
+ ```
37
+ npx planche [options]
38
+
39
+ -p, --port <n> Port to listen on (default: 8888)
40
+ --addr <host> Address to bind (default: 127.0.0.1)
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
+ --no-open Print the pairing link without opening a browser
45
+ --verbose Log every request
46
+ -h, --help Show help
47
+ ```
48
+
49
+ Every option can also be set through an environment variable:
50
+
51
+ | Variable | Option | Notes |
52
+ | ------------------------- | ---------- | --------------------------------------------------- |
53
+ | `PLANCHE_PORT` | `--port` | |
54
+ | `PLANCHE_ADDR` | `--addr` | |
55
+ | `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
+ | `PLANCHE_BODY_LIMIT` | | Max request body, default `50mb` (large SQL scripts) |
59
+ | `PLANCHE_SQLITE_DIR` | | Only SQLite files inside this directory may be opened |
60
+
61
+ ### Examples
62
+
63
+ Keep the same token between restarts so the app never needs re-pairing:
64
+
65
+ ```bash
66
+ PLANCHE_AUTH_TOKEN=my-secret npx planche
67
+ ```
68
+
69
+ Run on another port and print the link only:
70
+
71
+ ```bash
72
+ npx planche --port 9000 --no-open
73
+ ```
74
+
75
+ Use with the app served from your own host:
76
+
77
+ ```bash
78
+ npx planche --app https://planche.example.com
79
+ ```
80
+
81
+ Restrict SQLite access to one folder:
82
+
83
+ ```bash
84
+ PLANCHE_SQLITE_DIR=~/databases npx planche
85
+ ```
86
+
87
+ ## How it works
88
+
89
+ The agent is a small HTTP server with a handful of JSON endpoints
90
+ (`/connect`, `/query`, `/disconnect`, `/test-connection`, `/health`). The app
91
+ calls them with the bearer token from the pairing link; the agent keeps the
92
+ database connections open and forwards queries with the matching driver
93
+ (`mysql2`, `pg`, `better-sqlite3`).
94
+
95
+ - **MySQL / MariaDB** — switching databases uses `USE`, so open transactions
96
+ and session variables survive.
97
+ - **PostgreSQL** — one connection is bound to one database; the "database"
98
+ you pick in the app is a schema of it and is applied with `SET search_path`.
99
+ - **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
100
+ limit which files can be opened. (Small SQLite files can also be opened
101
+ directly in the browser without the agent.)
102
+
103
+ ## Security
104
+
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`.
109
+ - The agent binds to `127.0.0.1` by default, so only the browser on the same
110
+ machine can reach it. Binding to another address (`--addr 0.0.0.0`) exposes
111
+ your databases to the network — only do that behind a tunnel or firewall you
112
+ trust.
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.
115
+
116
+ ## Troubleshooting
117
+
118
+ - **The app says the agent is offline** — make sure `npx planche` is still
119
+ running and that the port in the pairing link matches. Corporate proxies or
120
+ browser extensions that block `localhost` requests can also cause this.
121
+ - **"Agent restarted, re-pair"** — the agent generated a new token. Open the
122
+ 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`.
125
+ - **Wrong Node version** — the SQLite driver ships prebuilt binaries for
126
+ Node 22+. Check with `node -v`, or run with `npx -p node@22 planche`.
127
+
128
+ ## License
129
+
130
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,882 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { randomUUID as randomUUID2 } from "crypto";
5
+ import { createRequire } from "module";
6
+ import { spawn } from "child_process";
7
+ import { parseArgs } from "util";
8
+
9
+ // src/server.ts
10
+ import express from "express";
11
+ import { timingSafeEqual } from "crypto";
12
+
13
+ // ../shared/contract.ts
14
+ var PROTOCOL_VERSION = 2;
15
+
16
+ // src/tunneling.ts
17
+ import { randomUUID } from "crypto";
18
+
19
+ // src/drivers/mysql.ts
20
+ import mysql from "mysql2/promise";
21
+ var MYSQL_TYPES = {
22
+ 0: "decimal",
23
+ 1: "tinyint",
24
+ 2: "smallint",
25
+ 3: "int",
26
+ 4: "float",
27
+ 5: "double",
28
+ 6: "null",
29
+ 7: "timestamp",
30
+ 8: "bigint",
31
+ 9: "mediumint",
32
+ 10: "date",
33
+ 11: "time",
34
+ 12: "datetime",
35
+ 13: "year",
36
+ 14: "newdate",
37
+ 15: "varchar",
38
+ 16: "bit",
39
+ 245: "json",
40
+ 246: "newdecimal",
41
+ 247: "enum",
42
+ 248: "set",
43
+ 249: "tinyblob",
44
+ 250: "mediumblob",
45
+ 251: "longblob",
46
+ 252: "blob",
47
+ 253: "varstring",
48
+ 254: "string",
49
+ 255: "geometry"
50
+ };
51
+ function getTypeName(typeId) {
52
+ return MYSQL_TYPES[typeId] ?? "unknown";
53
+ }
54
+ function toConnectionOptions(config) {
55
+ return {
56
+ host: config.host,
57
+ port: config.port,
58
+ user: config.user,
59
+ password: config.password,
60
+ database: config.database || void 0,
61
+ dateStrings: true,
62
+ charset: config.charset || "utf8mb4"
63
+ };
64
+ }
65
+ var USE_STATEMENT = /^\s*USE\s/i;
66
+ var MySqlDriver = class {
67
+ constructor(connection, config) {
68
+ this.connection = connection;
69
+ this.label = `${config.user}@${config.host}:${config.port}`;
70
+ this.currentDatabase = config.database ?? null;
71
+ }
72
+ connection;
73
+ type = "mysql";
74
+ label;
75
+ /**
76
+ * Default schema the session is currently in, or null when unknown (after
77
+ * the client ran its own `USE`). Tracked so the schema is switched with a
78
+ * plain `USE` only when it actually changes: `changeUser()` would reset the
79
+ * whole session on every call, silently rolling back open transactions and
80
+ * dropping temporary tables and user variables.
81
+ */
82
+ currentDatabase;
83
+ async query(sql, database) {
84
+ if (database && database !== this.currentDatabase) {
85
+ await this.connection.query(`USE ${mysql.escapeId(database)}`);
86
+ this.currentDatabase = database;
87
+ }
88
+ const [results, fields] = await this.connection.query(sql);
89
+ if (USE_STATEMENT.test(sql)) this.currentDatabase = null;
90
+ if (Array.isArray(results) && fields) {
91
+ const fieldPackets = fields;
92
+ const mappedFields = fieldPackets.map((field) => ({
93
+ name: field.name,
94
+ orgName: field.orgName ?? field.name,
95
+ type: getTypeName(field.type ?? 253),
96
+ table: field.table ?? "",
97
+ orgTable: field.orgTable ?? "",
98
+ db: field.db ?? "",
99
+ maxLength: field.length ?? 0
100
+ }));
101
+ const records = results.map(
102
+ (row) => fieldPackets.map((field) => row[field.name])
103
+ );
104
+ return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
105
+ }
106
+ const header = results;
107
+ return {
108
+ fields: [],
109
+ records: [],
110
+ affectedRows: header.affectedRows ?? 0,
111
+ insertId: header.insertId ?? 0,
112
+ isResultQuery: false
113
+ };
114
+ }
115
+ async close() {
116
+ await this.connection.end();
117
+ }
118
+ };
119
+ var mysqlDriverFactory = {
120
+ async open(config) {
121
+ const connection = await mysql.createConnection(toConnectionOptions(config));
122
+ return new MySqlDriver(connection, config);
123
+ },
124
+ async test(config) {
125
+ const connection = await mysql.createConnection({
126
+ ...toConnectionOptions(config),
127
+ database: void 0
128
+ });
129
+ await connection.end();
130
+ },
131
+ parseConfig(body) {
132
+ const host = typeof body.host === "string" ? body.host.trim() : "";
133
+ const user = typeof body.user === "string" ? body.user : "";
134
+ if (!host || !user) {
135
+ throw new Error("host and user are required");
136
+ }
137
+ return {
138
+ driver: "mysql",
139
+ host,
140
+ port: Number(body.port) || 3306,
141
+ user,
142
+ password: typeof body.password === "string" ? body.password : "",
143
+ database: typeof body.database === "string" && body.database ? body.database : void 0,
144
+ charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
145
+ };
146
+ }
147
+ };
148
+
149
+ // src/drivers/postgres.ts
150
+ import pg from "pg";
151
+ var TEXT_OIDS = [
152
+ 20,
153
+ // int8
154
+ 1700,
155
+ // numeric
156
+ 1082,
157
+ // date
158
+ 1114,
159
+ // timestamp
160
+ 1184,
161
+ // timestamptz
162
+ 1083,
163
+ // time
164
+ 1266,
165
+ // timetz
166
+ 1186
167
+ // interval
168
+ ];
169
+ var types = new pg.TypeOverrides();
170
+ for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
171
+ var BUILTIN_TYPES = {
172
+ 16: "bool",
173
+ 17: "bytea",
174
+ 18: "char",
175
+ 19: "name",
176
+ 20: "int8",
177
+ 21: "int2",
178
+ 23: "int4",
179
+ 25: "text",
180
+ 26: "oid",
181
+ 114: "json",
182
+ 142: "xml",
183
+ 700: "float4",
184
+ 701: "float8",
185
+ 1042: "bpchar",
186
+ 1043: "varchar",
187
+ 1082: "date",
188
+ 1083: "time",
189
+ 1114: "timestamp",
190
+ 1184: "timestamptz",
191
+ 1186: "interval",
192
+ 1266: "timetz",
193
+ 1700: "numeric",
194
+ 2950: "uuid",
195
+ 3802: "jsonb",
196
+ 1560: "bit",
197
+ 1562: "varbit",
198
+ 600: "point",
199
+ 869: "inet",
200
+ 650: "cidr",
201
+ 829: "macaddr",
202
+ 1007: "_int4",
203
+ 1009: "_text"
204
+ };
205
+ function toClientConfig(config) {
206
+ return {
207
+ host: config.host,
208
+ port: config.port,
209
+ user: config.user,
210
+ password: config.password,
211
+ database: config.database,
212
+ ssl: config.ssl ? { rejectUnauthorized: false } : void 0,
213
+ types
214
+ };
215
+ }
216
+ var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
217
+ var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
218
+ var PostgresDriver = class {
219
+ constructor(client, config) {
220
+ this.client = client;
221
+ this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
222
+ }
223
+ client;
224
+ type = "postgres";
225
+ label;
226
+ /**
227
+ * Schema currently first in `search_path`, or null when unknown (after the
228
+ * client set it itself). The app's "database" for a Postgres connection is
229
+ * a schema of the bound database, switched with `SET search_path`.
230
+ */
231
+ currentSchema = null;
232
+ typeNames = { ...BUILTIN_TYPES };
233
+ /**
234
+ * Result fields name their source only by OID (`tableID` / `columnID`);
235
+ * the frontend needs schema/table/column names to decide whether a grid is
236
+ * editable and to build UPDATEs, so they are resolved here and cached.
237
+ */
238
+ relations = /* @__PURE__ */ new Map();
239
+ async loadTypeNames() {
240
+ try {
241
+ const res = await this.client.query(
242
+ "SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
243
+ );
244
+ for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
245
+ } catch {
246
+ }
247
+ }
248
+ async resolveRelations(tableIds) {
249
+ const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
250
+ if (missing.length === 0) return;
251
+ try {
252
+ const res = await this.client.query(
253
+ `SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
254
+ FROM pg_class c
255
+ JOIN pg_namespace n ON n.oid = c.relnamespace
256
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
257
+ WHERE c.oid = ANY($1::oid[])`,
258
+ [missing]
259
+ );
260
+ for (const row of res.rows) {
261
+ const oid = Number(row.oid);
262
+ let rel = this.relations.get(oid);
263
+ if (!rel) {
264
+ rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
265
+ this.relations.set(oid, rel);
266
+ }
267
+ rel.columns.set(Number(row.attnum), row.attname);
268
+ }
269
+ } catch {
270
+ }
271
+ }
272
+ async query(sql, database) {
273
+ if (database && database !== this.currentSchema) {
274
+ await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
275
+ this.currentSchema = database;
276
+ }
277
+ const result = await this.client.query({ text: sql, rowMode: "array" });
278
+ if (SEARCH_PATH_STATEMENT.test(sql)) this.currentSchema = null;
279
+ const results = Array.isArray(result) ? result : [result];
280
+ const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
281
+ if (last.fields.length > 0) {
282
+ await this.resolveRelations(last.fields.map((f) => f.tableID));
283
+ const fields = last.fields.map((field) => {
284
+ const rel = this.relations.get(field.tableID);
285
+ return {
286
+ name: field.name,
287
+ orgName: rel?.columns.get(field.columnID) ?? field.name,
288
+ type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
289
+ table: rel?.table ?? "",
290
+ orgTable: rel?.table ?? "",
291
+ db: rel?.schema ?? "",
292
+ maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
293
+ };
294
+ });
295
+ return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
296
+ }
297
+ return {
298
+ fields: [],
299
+ records: [],
300
+ affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
301
+ insertId: 0,
302
+ isResultQuery: false
303
+ };
304
+ }
305
+ async close() {
306
+ await this.client.end();
307
+ }
308
+ };
309
+ var postgresDriverFactory = {
310
+ async open(config) {
311
+ const client = new pg.Client(toClientConfig(config));
312
+ await client.connect();
313
+ const driver = new PostgresDriver(client, config);
314
+ await driver.loadTypeNames();
315
+ return driver;
316
+ },
317
+ async test(config) {
318
+ const client = new pg.Client(toClientConfig(config));
319
+ await client.connect();
320
+ await client.end();
321
+ },
322
+ parseConfig(body) {
323
+ const host = typeof body.host === "string" ? body.host.trim() : "";
324
+ const user = typeof body.user === "string" ? body.user : "";
325
+ if (!host || !user) {
326
+ throw new Error("host and user are required");
327
+ }
328
+ return {
329
+ driver: "postgres",
330
+ host,
331
+ port: Number(body.port) || 5432,
332
+ user,
333
+ password: typeof body.password === "string" ? body.password : "",
334
+ database: typeof body.database === "string" && body.database ? body.database : "postgres",
335
+ ssl: body.ssl === true
336
+ };
337
+ }
338
+ };
339
+
340
+ // src/drivers/sqlite.ts
341
+ import Database from "better-sqlite3";
342
+ import { existsSync } from "fs";
343
+ import { isAbsolute, resolve, sep } from "path";
344
+ function normalizeDeclaredType(declared) {
345
+ if (!declared) return null;
346
+ const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
347
+ return base || null;
348
+ }
349
+ function inferTypeFromValue(value) {
350
+ if (value === null || value === void 0) return "null";
351
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
352
+ if (typeof value === "bigint") return "bigint";
353
+ if (Buffer.isBuffer(value)) return "blob";
354
+ return "text";
355
+ }
356
+ var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(process.env.PLANCHE_SQLITE_DIR) : null;
357
+ function resolveFile(file) {
358
+ if (file === ":memory:") return file;
359
+ if (file.startsWith("file:")) {
360
+ if (SQLITE_DIR) throw new Error("URI filenames are disabled while PLANCHE_SQLITE_DIR is set");
361
+ return file;
362
+ }
363
+ const resolved = isAbsolute(file) ? file : resolve(SQLITE_DIR ?? process.cwd(), file);
364
+ if (SQLITE_DIR && resolved !== SQLITE_DIR && !resolved.startsWith(SQLITE_DIR + sep)) {
365
+ throw new Error(`SQLite file must be inside ${SQLITE_DIR}`);
366
+ }
367
+ return resolved;
368
+ }
369
+ function openDatabase(config) {
370
+ const file = resolveFile(config.file);
371
+ const isMemory = file === ":memory:";
372
+ if (!isMemory && !config.createIfMissing && !existsSync(file)) {
373
+ throw new Error(`SQLite file not found: ${file}`);
374
+ }
375
+ return new Database(file, {
376
+ readonly: config.readonly ?? false,
377
+ fileMustExist: !isMemory && !config.createIfMissing
378
+ });
379
+ }
380
+ var SqliteDriver = class {
381
+ constructor(db, config) {
382
+ this.db = db;
383
+ this.label = resolveFile(config.file);
384
+ }
385
+ db;
386
+ type = "sqlite";
387
+ label;
388
+ async query(sql) {
389
+ let statement;
390
+ try {
391
+ statement = this.db.prepare(sql);
392
+ } catch (err) {
393
+ if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
394
+ this.db.exec(sql);
395
+ return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
396
+ }
397
+ throw err;
398
+ }
399
+ if (!statement.reader) {
400
+ const info = statement.run();
401
+ return {
402
+ fields: [],
403
+ records: [],
404
+ affectedRows: info.changes,
405
+ insertId: Number(info.lastInsertRowid),
406
+ isResultQuery: false
407
+ };
408
+ }
409
+ const columns = statement.columns();
410
+ const records = statement.raw(true).all();
411
+ const fields = columns.map((col, index) => {
412
+ const declared = normalizeDeclaredType(col.type);
413
+ const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
414
+ return {
415
+ name: col.name,
416
+ orgName: col.column ?? col.name,
417
+ type,
418
+ table: col.table ?? "",
419
+ orgTable: col.table ?? "",
420
+ db: col.database ?? "",
421
+ maxLength: 0
422
+ };
423
+ });
424
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
425
+ }
426
+ async close() {
427
+ if (this.db.inTransaction) {
428
+ try {
429
+ this.db.exec("ROLLBACK");
430
+ } catch {
431
+ }
432
+ }
433
+ this.db.close();
434
+ }
435
+ };
436
+ function firstNonNull(records, index) {
437
+ for (const row of records) {
438
+ const value = row[index];
439
+ if (value !== null && value !== void 0) return value;
440
+ }
441
+ return null;
442
+ }
443
+ var sqliteDriverFactory = {
444
+ async open(config) {
445
+ return new SqliteDriver(openDatabase(config), config);
446
+ },
447
+ async test(config) {
448
+ const db = openDatabase(config);
449
+ try {
450
+ db.prepare("SELECT 1").get();
451
+ } finally {
452
+ db.close();
453
+ }
454
+ },
455
+ parseConfig(body) {
456
+ const file = typeof body.file === "string" ? body.file.trim() : "";
457
+ if (!file) {
458
+ throw new Error("file is required");
459
+ }
460
+ return {
461
+ driver: "sqlite",
462
+ file,
463
+ createIfMissing: body.createIfMissing === true,
464
+ readonly: body.readonly === true
465
+ };
466
+ }
467
+ };
468
+
469
+ // src/drivers/index.ts
470
+ var factories = {
471
+ mysql: mysqlDriverFactory,
472
+ postgres: postgresDriverFactory,
473
+ sqlite: sqliteDriverFactory
474
+ };
475
+ function isDriverType(value) {
476
+ return typeof value === "string" && value in factories;
477
+ }
478
+ function parseDriverConfig(body) {
479
+ const driver = body.driver ?? "mysql";
480
+ if (!isDriverType(driver)) {
481
+ throw new Error(`Unsupported driver: ${String(driver)}`);
482
+ }
483
+ return factories[driver].parseConfig(body);
484
+ }
485
+ function openDriver(config) {
486
+ switch (config.driver) {
487
+ case "mysql":
488
+ return factories.mysql.open(config);
489
+ case "postgres":
490
+ return factories.postgres.open(config);
491
+ case "sqlite":
492
+ return factories.sqlite.open(config);
493
+ }
494
+ }
495
+ function testDriver(config) {
496
+ switch (config.driver) {
497
+ case "mysql":
498
+ return factories.mysql.test(config);
499
+ case "postgres":
500
+ return factories.postgres.test(config);
501
+ case "sqlite":
502
+ return factories.sqlite.test(config);
503
+ }
504
+ }
505
+
506
+ // src/tunneling.ts
507
+ var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
508
+ var ConnectionManager = class {
509
+ connections = /* @__PURE__ */ new Map();
510
+ lastUsed = /* @__PURE__ */ new Map();
511
+ constructor() {
512
+ const timer = setInterval(() => void this.closeIdle(), 6e4);
513
+ timer.unref();
514
+ }
515
+ /** Close connections idle for longer than `IDLE_TIMEOUT_MS`. */
516
+ async closeIdle() {
517
+ const cutoff = Date.now() - IDLE_TIMEOUT_MS;
518
+ for (const [id, used] of this.lastUsed) {
519
+ if (used < cutoff) {
520
+ console.log(`[ConnectionManager] Closing idle connection: ${id}`);
521
+ await this.disconnect(id).catch(() => void 0);
522
+ }
523
+ }
524
+ }
525
+ /**
526
+ * Open a new connection and return a unique connection ID.
527
+ */
528
+ async connect(config) {
529
+ const driver = await openDriver(config);
530
+ const connectionId = randomUUID();
531
+ this.connections.set(connectionId, driver);
532
+ this.lastUsed.set(connectionId, Date.now());
533
+ console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
534
+ return connectionId;
535
+ }
536
+ has(connectionId) {
537
+ return this.connections.has(connectionId);
538
+ }
539
+ /**
540
+ * Execute a SQL query on an existing connection.
541
+ * Optionally switch database before executing (drivers that support it).
542
+ */
543
+ async query(connectionId, sql, database) {
544
+ const driver = this.connections.get(connectionId);
545
+ if (!driver) {
546
+ throw new Error(`Connection not found: ${connectionId}`);
547
+ }
548
+ this.lastUsed.set(connectionId, Date.now());
549
+ return driver.query(sql, database);
550
+ }
551
+ /**
552
+ * Close and remove a connection.
553
+ */
554
+ async disconnect(connectionId) {
555
+ const driver = this.connections.get(connectionId);
556
+ if (!driver) {
557
+ throw new Error(`Connection not found: ${connectionId}`);
558
+ }
559
+ await driver.close();
560
+ this.connections.delete(connectionId);
561
+ this.lastUsed.delete(connectionId);
562
+ console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
563
+ }
564
+ /**
565
+ * Validate a config by opening and immediately closing a connection.
566
+ */
567
+ async testConnection(config) {
568
+ await testDriver(config);
569
+ return true;
570
+ }
571
+ /**
572
+ * Get the number of active connections.
573
+ */
574
+ get activeConnections() {
575
+ return this.connections.size;
576
+ }
577
+ /**
578
+ * Close all connections (for graceful shutdown).
579
+ */
580
+ async disconnectAll() {
581
+ const promises = Array.from(this.connections.entries()).map(
582
+ async ([id, driver]) => {
583
+ try {
584
+ await driver.close();
585
+ console.log(`[ConnectionManager] Disconnected: ${id}`);
586
+ } catch {
587
+ }
588
+ }
589
+ );
590
+ await Promise.all(promises);
591
+ this.connections.clear();
592
+ this.lastUsed.clear();
593
+ }
594
+ };
595
+
596
+ // src/server.ts
597
+ function fail(res, status, message) {
598
+ res.status(status).json({ success: false, message });
599
+ }
600
+ var errorMessage = (err) => err instanceof Error ? err.message : String(err);
601
+ function tokenMatches(header, expected) {
602
+ if (!header?.startsWith("Bearer ")) return false;
603
+ const given = Buffer.from(header.slice(7));
604
+ const want = Buffer.from(expected);
605
+ return given.length === want.length && timingSafeEqual(given, want);
606
+ }
607
+ function accessControl(allowed) {
608
+ return (req, res, next) => {
609
+ const origin = req.headers.origin;
610
+ if (origin === void 0) {
611
+ next();
612
+ return;
613
+ }
614
+ if (!allowed.has(origin)) {
615
+ fail(res, 403, `Origin not allowed: ${origin}`);
616
+ return;
617
+ }
618
+ res.setHeader("Access-Control-Allow-Origin", origin);
619
+ res.setHeader("Vary", "Origin");
620
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
621
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
622
+ res.setHeader("Access-Control-Max-Age", "600");
623
+ if (req.headers["access-control-request-private-network"] === "true") {
624
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
625
+ }
626
+ if (req.headers["access-control-request-local-network"] === "true") {
627
+ res.setHeader("Access-Control-Allow-Local-Network", "true");
628
+ }
629
+ if (req.method === "OPTIONS") {
630
+ res.sendStatus(204);
631
+ return;
632
+ }
633
+ next();
634
+ };
635
+ }
636
+ function createAgent(options) {
637
+ const app = express();
638
+ const manager = new ConnectionManager();
639
+ const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
640
+ if (options.logRequests) {
641
+ app.use((req, _res, next) => {
642
+ const hasAuth = !!req.headers.authorization;
643
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.url} (auth: ${hasAuth})`);
644
+ next();
645
+ });
646
+ }
647
+ app.use(accessControl(allowedOrigins));
648
+ app.use(express.json({ limit: options.bodyLimit }));
649
+ app.use((req, res, next) => {
650
+ if (req.path === "/health") {
651
+ next();
652
+ return;
653
+ }
654
+ if (!tokenMatches(req.headers.authorization, options.authToken)) {
655
+ fail(res, 401, "Unauthorized: invalid or missing auth token");
656
+ return;
657
+ }
658
+ next();
659
+ });
660
+ app.get("/health", (req, res) => {
661
+ const body = {
662
+ success: true,
663
+ name: "planche",
664
+ version: options.version,
665
+ protocolVersion: PROTOCOL_VERSION,
666
+ activeConnections: manager.activeConnections,
667
+ uptime: process.uptime()
668
+ };
669
+ if (req.headers.authorization !== void 0) {
670
+ body.authorized = tokenMatches(req.headers.authorization, options.authToken);
671
+ }
672
+ res.json(body);
673
+ });
674
+ app.post("/connect", async (req, res) => {
675
+ try {
676
+ const config = parseDriverConfig(req.body ?? {});
677
+ const connectionId = await manager.connect(config);
678
+ res.json({ success: true, connectionId, message: "Connection Successful" });
679
+ } catch (err) {
680
+ const message = errorMessage(err);
681
+ console.error("[connect] Error:", message);
682
+ fail(res, 400, message);
683
+ }
684
+ });
685
+ app.post("/query", async (req, res) => {
686
+ try {
687
+ const { connectionId, sql, database } = req.body;
688
+ if (typeof connectionId !== "string" || typeof sql !== "string" || !connectionId || !sql) {
689
+ fail(res, 400, "connectionId and sql are required");
690
+ return;
691
+ }
692
+ if (!manager.has(connectionId)) {
693
+ fail(res, 404, `Connection not found: ${connectionId}`);
694
+ return;
695
+ }
696
+ const start = Date.now();
697
+ const result = await manager.query(connectionId, sql, database);
698
+ const execTime = Date.now() - start;
699
+ res.json({
700
+ success: true,
701
+ fields: result.fields,
702
+ records: result.records,
703
+ affectedRows: result.affectedRows,
704
+ insertId: result.insertId,
705
+ isResultQuery: result.isResultQuery,
706
+ execTime
707
+ });
708
+ } catch (err) {
709
+ const message = errorMessage(err);
710
+ console.error("[query] Error:", message);
711
+ fail(res, 422, message);
712
+ }
713
+ });
714
+ app.post("/disconnect", async (req, res) => {
715
+ try {
716
+ const { connectionId } = req.body;
717
+ if (typeof connectionId !== "string" || !connectionId) {
718
+ fail(res, 400, "connectionId is required");
719
+ return;
720
+ }
721
+ if (!manager.has(connectionId)) {
722
+ fail(res, 404, `Connection not found: ${connectionId}`);
723
+ return;
724
+ }
725
+ await manager.disconnect(connectionId);
726
+ res.json({ success: true, message: "Disconnected" });
727
+ } catch (err) {
728
+ const message = errorMessage(err);
729
+ console.error("[disconnect] Error:", message);
730
+ fail(res, 500, message);
731
+ }
732
+ });
733
+ app.post("/test-connection", async (req, res) => {
734
+ try {
735
+ await manager.testConnection(parseDriverConfig(req.body ?? {}));
736
+ res.json({ success: true, message: "Connection test successful" });
737
+ } catch (err) {
738
+ const message = errorMessage(err);
739
+ console.error("[test-connection] Error:", message);
740
+ fail(res, 400, message);
741
+ }
742
+ });
743
+ return { app, manager };
744
+ }
745
+
746
+ // src/index.ts
747
+ var { version: VERSION } = createRequire(import.meta.url)("../package.json");
748
+ var DEFAULT_APP_URL = "https://planche.dev";
749
+ var DEV_ORIGINS = [
750
+ "http://localhost:5173",
751
+ "http://127.0.0.1:5173",
752
+ "http://localhost:4173",
753
+ "http://127.0.0.1:4173"
754
+ ];
755
+ function usage() {
756
+ return `planche ${VERSION} \u2014 local agent for the Planche SQL client
757
+
758
+ Usage: planche [options]
759
+
760
+ Options:
761
+ -p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
762
+ --addr <host> Address to bind (default: 127.0.0.1, env PLANCHE_ADDR)
763
+ --token <str> Auth token (default: random, env PLANCHE_AUTH_TOKEN)
764
+ --app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
765
+ --origin <url> Extra browser origin allowed to use the agent (repeatable,
766
+ env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
767
+ --no-open Do not open the browser
768
+ --verbose Log every request
769
+ -v, --version Print the version
770
+ -h, --help Show this help
771
+ `;
772
+ }
773
+ function splitList(value) {
774
+ return (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
775
+ }
776
+ function originOf(url) {
777
+ try {
778
+ return new URL(url).origin;
779
+ } catch {
780
+ return null;
781
+ }
782
+ }
783
+ function openInBrowser(url) {
784
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
785
+ try {
786
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
787
+ } catch {
788
+ }
789
+ }
790
+ function main() {
791
+ let values;
792
+ try {
793
+ values = parseArgs({
794
+ options: {
795
+ port: { type: "string", short: "p" },
796
+ addr: { type: "string" },
797
+ token: { type: "string" },
798
+ app: { type: "string" },
799
+ origin: { type: "string", multiple: true },
800
+ open: { type: "boolean" },
801
+ verbose: { type: "boolean" },
802
+ version: { type: "boolean", short: "v" },
803
+ help: { type: "boolean", short: "h" }
804
+ },
805
+ allowNegative: true,
806
+ strict: true
807
+ }).values;
808
+ } catch (err) {
809
+ console.error(err instanceof Error ? err.message : String(err));
810
+ console.error(usage());
811
+ process.exit(2);
812
+ }
813
+ const flags = values;
814
+ if (flags.help) {
815
+ console.log(usage());
816
+ return;
817
+ }
818
+ if (flags.version) {
819
+ console.log(VERSION);
820
+ return;
821
+ }
822
+ const port = Number(flags.port ?? process.env.PLANCHE_PORT) || 8888;
823
+ const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
824
+ const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
825
+ const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
826
+ const appUrl = (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
827
+ const shouldOpen = flags.open ?? true;
828
+ const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
829
+ const appOrigin = originOf(appUrl);
830
+ if (!appOrigin) {
831
+ console.error(`Invalid --app URL: ${appUrl}`);
832
+ process.exit(2);
833
+ }
834
+ const allowedOrigins = [
835
+ appOrigin,
836
+ ...DEV_ORIGINS,
837
+ ...flags.origin ?? [],
838
+ ...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)
839
+ ];
840
+ const { app, manager } = createAgent({
841
+ authToken,
842
+ allowedOrigins,
843
+ bodyLimit,
844
+ version: VERSION,
845
+ logRequests: flags.verbose ?? false
846
+ });
847
+ const agentUrl = `http://${addr}:${port}`;
848
+ const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&token=${encodeURIComponent(authToken)}`;
849
+ const server = app.listen(port, addr, () => {
850
+ const line = "-".repeat(72);
851
+ console.log(line);
852
+ console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
853
+ console.log(line);
854
+ console.log(`Open the app with:
855
+
856
+ ${pairUrl}
857
+ `);
858
+ console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token: ${authToken}`);
859
+ console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
860
+ if (!["127.0.0.1", "localhost", "::1"].includes(addr)) {
861
+ console.log(`WARNING: bound to ${addr} \u2014 anyone who can reach this port and knows the token can use your databases.`);
862
+ }
863
+ console.log(line);
864
+ if (shouldOpen) openInBrowser(pairUrl);
865
+ });
866
+ server.on("error", (err) => {
867
+ if (err.code === "EADDRINUSE") {
868
+ console.error(`Port ${port} is already in use. Is another agent running? Use --port to pick another.`);
869
+ } else {
870
+ console.error(err.message);
871
+ }
872
+ process.exit(1);
873
+ });
874
+ const shutdown = async () => {
875
+ console.log("\nShutting down...");
876
+ await manager.disconnectAll();
877
+ process.exit(0);
878
+ };
879
+ process.on("SIGINT", shutdown);
880
+ process.on("SIGTERM", shutdown);
881
+ }
882
+ main();
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "planche",
3
+ "version": "0.1.0",
4
+ "description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
5
+ "license": "MIT",
6
+ "homepage": "https://planche.dev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/plancheproject/planche-reboot.git",
10
+ "directory": "server"
11
+ },
12
+ "type": "module",
13
+ "bin": "dist/index.js",
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "keywords": [
25
+ "planche",
26
+ "mysql",
27
+ "mariadb",
28
+ "postgres",
29
+ "postgresql",
30
+ "sqlite",
31
+ "sql",
32
+ "client",
33
+ "gui"
34
+ ],
35
+ "scripts": {
36
+ "dev": "tsx watch --env-file-if-exists=.env src/index.ts --no-open --app http://localhost:5173",
37
+ "start": "tsx --env-file-if-exists=.env src/index.ts",
38
+ "build": "tsup",
39
+ "typecheck": "tsc --noEmit -p .",
40
+ "prepublishOnly": "yarn typecheck && yarn build"
41
+ },
42
+ "dependencies": {
43
+ "better-sqlite3": "^12.11.1",
44
+ "express": "^5.1.0",
45
+ "mysql2": "^3.14.1",
46
+ "pg": "^8.23.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/better-sqlite3": "^9.6.0",
50
+ "@types/express": "^5.0.0",
51
+ "@types/node": "^24.0.0",
52
+ "@types/pg": "^8.23.1",
53
+ "tsup": "^8.5.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.9.0"
56
+ }
57
+ }